che-ical-mcp
Provides native EventKit integration for managing Apple Calendar events and Reminders, including support for task tagging, conflict detection, and batch operations.
Enables direct interaction with macOS system calendars and reminder lists to manage schedules, track tasks, and perform multi-keyword searches.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
che-ical-mcp
Give Claude native control of macOS Calendar and Reminders. A Swift MCP server built directly on EventKit — 29 tools for events, reminders, tags, batch operations, conflict detection, and undo/redo. Not just calendar events: it drives Reminders and tasks too.
Install
Claude Code — register this repo as a marketplace, then install the plugin. The plugin bundles the /today, /week, /quick-event, /remind slash commands and a PreToolUse hook that verifies day-of-week on every event write:
claude plugin marketplace add PsychQuant/che-ical-mcp
claude plugin install che-ical-mcp@che-ical-mcpClaude Desktop — download the latest .mcpb from Releases and double-click to install.
Standalone MCP — the 29-tool server on its own, no plugin extras:
mkdir -p ~/bin
curl -L https://github.com/PsychQuant/che-ical-mcp/releases/latest/download/CheICalMCP -o ~/bin/CheICalMCP && chmod +x ~/bin/CheICalMCP
claude mcp add --scope user --transport stdio che-ical-mcp -- ~/bin/CheICalMCPOn first use, macOS prompts for Calendar and Reminders access — click Allow. Building from source, upgrading in place, or running under SSH / launchd / VS Code? See Installation for the full guide.
Related MCP server: apple-calendar-mcp
Why che-ical-mcp?
Feature | Other Calendar MCPs | che-ical-mcp |
Calendar Events | Yes | Yes |
Reminders/Tasks | No | Yes |
Reminder #Tags | No | Yes (MCP-level) |
Multi-keyword Search | No | Yes |
Duplicate Detection | No | Yes |
Conflict Detection | No | Yes |
Batch Operations | No | Yes |
Local Timezone | No | Yes |
Source Disambiguation | No | Yes |
Create Calendar | Some | Yes |
Delete Calendar | Some | Yes |
Event Reminders | Some | Yes |
Location & URL | Some | Yes |
Language | Python | Swift (Native) |
All 29 Tools
Tool | Description |
| List all calendars and reminder lists (includes source_type) |
| Create a new calendar. A title is 1 to 255 characters and not only whitespace, also on |
| Delete a calendar or reminder list. This cannot be undone: it deletes every event or reminder in it, and |
| Rename a calendar or change its color (v0.9.0). Not undoable (#273): |
Tool | Description |
| List events with filter/sort/limit (v1.0.0) |
| Create an event (with reminders, location, URL, per-event timezone, recurrence with excluded dates) |
| Update an event (including timezone, recurrence, span for recurring) |
| Delete an event (with occurrence support for recurring) |
A reminder's identifier given to an event tool (#260):
update_event,delete_event,copy_event,move_events_batchanddelete_events_batchanswerEvent not found: <id>(a row error in the batch tools) instead of ending the server. Without Reminders access EventKit may not report the item as a reminder; the event lookup's Objective-C exception is then caught, and the tool answers that the lookup failed and the event may still exist (#288). That was measured with the kind check switched off, and a run with Reminders access actually denied has not been done. The check itself (calendarItem(withIdentifier:)) is not wrapped, so if it raises there, the process can still end (#293).
Recurrence exclusions (#182):
create_event(and eachcreate_events_batchitem) acceptsrecurrence.excluded_occurrence_datesto skip specific dates in a recurring series at creation time — applied with best-effort all-or-nothing semantics (any failure removes the entire new series via compensating delete; rollback failure is reported, never silent; the first occurrence cannot be excluded), and oneundoremoves the whole series including exclusions. The excluded dates are removed occurrences, not edited ones (checked on iCloud only, 2026-10-05; other stores were not checked), so they do not stop that undo; an occurrence edited on its own after the series was created does (#236). Known limitation: on idempotent retry, a duplicate recurring series with every requested date already absent isskipped, but extra exclusions already present on the existing series (beyond those requested) are not detected.
Tool | Description |
| List reminders with filter/sort/limit, tags extraction (v1.0.0) |
| Create a reminder with due date, tags (v1.3.0); a bare |
| Update a reminder (including tags, |
| Mark as completed/incomplete |
| Delete a reminder |
| Search reminders by keyword(s) or tag (v1.3.0) |
| List all unique tags with usage counts (v1.3.0) |
| Delete all completed reminders in one call, dry_run preview by default (v1.7.2); one |
Recurring reminders (#194): list/search include has_recurrence, complete public recurrence_rules, and precision-aware due. Completion adds operation (write outcome) and next_occurrence (confirmed/unknown/not_applicable). Use operation.status rather than legacy is_completed: a successful completion may leave the next occurrence incomplete. Unknown successor information must not trigger a second write. A different-ID or unconfirmable successor is reported as unknown, never as the series having ended. Undo of a recurring completion is identity-guarded (#204): once the identifier no longer resolves to the recorded occurrence it refuses explicitly and drops its history entry instead of jamming the stack. Breaking (#205): completed must be a JSON boolean on complete_reminder / list_reminders / search_reminders — strings and numbers are rejected; omit or null keeps the old meaning. Breaking (#207, unreleased): the same contract now applies to every boolean tool argument (all_day, clear_*, include_completed, dry_run, delete_original). See response contract and limitations.
Rescheduling reminders (#227/#235/#237): In the rows measured, the Reminders.app list showed a reminder's earliest time-based alarm (#433), so update_reminder with a timed due_date moves the start date and every absolute-date alarm along with the due date (a bare date makes the reminder date-only instead, see below; by calendar days when the old due date has no time; otherwise by the exact difference). clear_due_date also clears the start date and removes absolute-date alarms. A move keeps any offset that already exists, so to repair a reminder whose alarm already disagrees with its due date pass realign_to_due: true (with due_date, or alone for the current due date; relative alarms stay, so an early relative alarm still sets what the list shows): the earliest absolute-date alarm lands on the due date, later ones keep their spacing after it, and the start date is set to the due date unless it already agrees with it (any time on its day for a date-only due; the due time, or its day at midnight or without a time, for a timed due). The response reports what moved in date_sync, and date_sync.aligned says whether, on the saved reminder, the start date and the earliest absolute-date alarm agree with the due date (an alarm set apart on purpose also reads false). A timed due date written to a floating reminder, such as a date-only one made in Reminders.app, keeps its explicit time zone. Undo restores the start date and alarms, location alarms included (#228).
Alarms after completing a repeating reminder (#423): completing a repeating reminder that has a location trigger leaves one plain relative alarm at 0 minutes (no location) on the next occurrence and on the completed copy (measured on iCloud; it does not appear without a location trigger, without recurrence or without a completion, and a later completion does not add another, whether the alarm is still there or was removed). Completing a reminder sets only the completion fields; EventKit or the store adds this alarm when that save rolls the reminder over, and which of the two was not told apart. After a confirmed rollover complete_reminder reads the next occurrence back and reports its relative alarms as next_occurrence.alarms_minutes_offsets, with alarms_note naming any that appeared on that read and were not there before the completion (an alarm from another app or a sync would look the same). When the next occurrence cannot be re-read, or changed in between, next_occurrence.alarms_read_back is false with alarms_read_back_note and no alarms are reported. With any other next_occurrence.status these fields are absent. Nothing is written (a default: removing the alarm was not chosen, since its purpose is unknown); update_reminder with alarms_minutes_offsets removed it in the cases tried. Whether it fires and whether Reminders.app shows it are not checked. The completed copy gets a new identifier and is not read.
Arguments are checked against the schema (#448): every tool refuses a key it does not read, and a value that is not the declared string, integer, number, array or object, with nothing done; the elements of an array of strings or integers are checked too. The message lists the accepted keys. A batch row fails alone. On a declared key this check treats JSON null as absent, though a handler may still refuse it (limit, for one); on an undeclared key it is refused like any other value.
Coordinates, radius, proximity and priority are checked (#473): structured_location (create_event, update_event, each create_events_batch row) and location_trigger (create_reminder, update_reminder) refuse a latitude outside -90 to 90, a longitude outside -180 to 180, and a radius of 0 or less; structured_location also refuses one coordinate without the other (give both or neither). location_trigger.proximity is required and must be exactly enter or leave. priority on create_reminder, update_reminder and each create_reminders_batch row must be an integer from 0 to 9; EventKit refused 10, -1 and 100 (measured on iCloud; Apple documents 0 to 9), so it is refused here before anything is written, not clamped, and JSON null counts as omitted on all three. Before, a radius of 0 or less became 100 or was dropped, half a coordinate pair was dropped, and any proximity other than leave (Leave, exit, none) was written as enter, all with no message. A batch row fails alone. A string array element that reads as a data URL (text starting data: with a comma) is refused with the same explanation a string argument gets.
recurrence keys are checked (#434): create_event, create_events_batch, update_event, create_reminder and update_reminder refuse any key in recurrence other than frequency, interval, end_date, occurrence_count, days_of_week, days_of_month and, on the two create-event tools, excluded_occurrence_dates, and write nothing for that item. Such a key used to be ignored, so count in place of occurrence_count created a series that never ends. The same tools refuse, and write nothing for that item: end_date together with occurrence_count; a non-empty days_of_month on a daily or weekly rule, which EventKit stores but does not use (measured on iCloud; monthly and yearly rules use it, and every frequency uses days_of_week); and any key of recurrence, or a read tool's recurrence_rules, event_recurrence_rules or reminder_recurrence_rules beside recurrence instead of inside it.
Moving a series, and span future from a later occurrence (#451): two closed rules on a series that has started, checked before anything is written; otherwise occurrences that happened would be gone, moved to days they never had, or added in the past, and undo refuses it (measured on iCloud). From an occurrence that has happened (the first for span all or span future from it, the one named for span future from a later one), start_time may change only the time of day, on that occurrence's own date; any other date, earlier or later, is refused. From an occurrence still to come, and on a series that has not started, a start before now is refused. A recurrence from an occurrence that has happened must keep the stored pattern (frequency, interval, days_of_week, days_of_month) with its own bound, or, while the series has occurrences to come, no bound, an end_date at or after now, or an occurrence_count at least the stored one. On a series with an occurrence_count, a bound other than its own passes only with span all (or span future from the first occurrence) while the first occurrence is not deleted, on a rule with one slot a period; an end_date before now is refused. To change other fields, leave start_time and recurrence out; to move or change the rule from an occurrence still to come, use span future with its occurrence_date. A timezone other than the event's, or clear_timezone on an event that has a zone, from an occurrence that has happened is refused too (#478): the store repeats the occurrences at the new zone's wall-clock time, which moved happened ones across a daylight-saving change and lost the last one of a counted series (measured on iCloud); span future from an occurrence still to come (not where the series' first occurrence was edited on its own, or deleted with the first one left edited on its own: there no identifier takes it yet) kept the earlier occurrences for timezone; no route for clear_timezone was measured. Only the event's own identifier (item_timezone) counts as no change, and it is not written again; another spelling of the same zone, such as ROC for Asia/Taipei, is a change, and so is any zone, this Mac's included, on an event with no zone of its own (item_timezone null). These rules cover start_time, recurrence, timezone and clear_timezone only: a one-off event in the past given a rule repeats from its day, adding occurrences between it and now; clear_recurrence with span all deletes the occurrences after the first (#439); and #442 and #455 are open. A weekly rule repeating every two weeks or more with a week start other than Monday counts as one recurrence cannot write when that week start splits its days and the series' first weekday between two weeks that Monday would keep together, or the reverse (a rewrite gets Monday and would move occurrences by a week): on a started series its rule is not changed from an occurrence that happened. Biweekly Tuesday from a Tuesday start is not affected, nor, with a Sunday week start (as Google writes it), Tuesday and Thursday. From a later occurrence over such a rule, calendar_name is refused with or without recurrence. From a later occurrence with span future, all_day and occurrence_count are refused, since occurrences went, and so is calendar_name alone, which moved the earlier occurrences too; from an occurrence still to come a rule with end_date or no bound, with start_time or with calendar_name, stays within the later occurrences (measured on iCloud).
A time change over a series with a deleted occurrence (#503): on iCloud, changing the start, end, all_day or time zone of more than one occurrence of a series (span all, or span future) brought back every occurrence deleted on its own among them (a time zone across a daylight-saving divergence), and undo cannot remove them again. Such a write is now refused before anything is written while one of those occurrences was deleted on its own, or while that cannot be ruled out: an open-ended series, one that ends about five years or more after its start, a count series whose end the check cannot place (a count with days of the month, a monthly or yearly count from the 29th to the 31st, a long weekly count on several days), a series read through its series' id after its first occurrence was deleted, or a rule the check does not follow (each refusal names the condition that applies). A recurrence written over several occurrences with another pattern (frequency, interval, days or count) goes through the same check, since whether it keeps the deletions was not measured (only a new end_date was, and it kept them), and so does a series with more than one rule; a weekly rule without days counts as the weekday of the series' original first slot, a monthly one as its day. So does writing the first occurrence's shown time back over the whole series when that occurrence was edited on its own, since its shown time is not the series' time. A change of the title, notes, location, url, alarms or structured_location, or a new end_date alone, is not affected: on iCloud these kept the deletions with span all and with span future from a later occurrence, before and after it (url, alarms and structured_location through an occurrence's own id on a count series only), and so did a new end_date together with a calendar of the same account from a later occurrence. On a series the check can follow to its end, and for a change that is not all_day, span future with the occurrence_date of an occurrence after the last deleted one, not edited on its own, changes the time from there on, and the earlier deletions stay deleted (measured for a new start alone; not measured for an end alone, a time zone, or a new start together with a time zone or a rule; a new rule pattern gets no route, and neither does a write that also moves the series to another account, which checks every occurrence from any one, or one through the first occurrence's own id); on any other series no update route keeps the deletions. A whole series moved to a calendar of another account (calendar_name with span all, or move_events_batch) is a create plus a delete; whether it keeps the deletions was not measured, so it goes through the same check and is refused while one is found or cannot be ruled out. With span future from a later occurrence, every occurrence of the series is checked, not only those from it on. An all-day series whose rule end falls less than a day after an occurrence's start is not followed either (how EventKit reads that end was not measured). Other stores were not measured.
A rule belongs to the series (PR #439): update_event refuses recurrence or clear_recurrence on one occurrence (occurrence_date with span this) and clear_recurrence with occurrence_date and span future, and writes nothing. In every release from v1.5.0 (measured on iCloud on v1.22.0, read at the other tags), a rule saved on one occurrence deleted the rest of the series, clear_recurrence with span future left only one event on the series' first day, and clear_recurrence with span this detached the occurrence as a one-off and left the series repeating. Use span all for the whole series, and span future with occurrence_date to change a rule from that occurrence on. To end a series at an occurrence, pass span all and recurrence with the series' whole rule and an end_date after that occurrence's start, such as 23:59:59 that day: a bare date ends before that day's occurrence, and later occurrences go, also one edited on its own (measured on iCloud). On a series that has started, an end_date before now is refused (#451). clear_recurrence with span all leaves one event on the first day and deletes the other occurrences. An id of the form <series>/RID=<n>, one occurrence edited on its own, is told the series' id. recurrence with clear_recurrence is refused too.
The start has to be on days_of_week (#454): create_event, create_events_batch and update_event refuse a start off the list on every frequency, not only weekly, and write nothing. On daily, monthly and yearly it used to leave a stray first occurrence (measured on iCloud). On update_event this covers a rule in the call and, on span all and future, the series' own rule. An update that leaves the start's day where it is passes on a series already off its days. Moving one occurrence of a non-weekly series on span this is not refused. A rule on a numbered weekday, such as the first Monday of the month, cannot be written by this tool, so such a series is told to change in Calendar.
The start has to be on days_of_month too (#461): on monthly and yearly rules, create_event, create_events_batch and update_event refuse a start that is not one of the rule's days_of_month, by the same rules as days_of_week, and write nothing; with no recurrence in the call, changing the start's date of such a series on span all or future is refused, on the list or off it (a move to another of its days put two occurrences on one day); on span all the message names the route measured for a monthly series (recurrence with every field of the rule, and start_time), on span future, span all or Calendar; and Calendar when the rule has a part recurrence cannot write. Span this on one occurrence is not checked. Measured on iCloud, such a start used to become a stray first occurrence, and moving a series' start onto another day made that day part of the rule. Reminders are not checked.
occurrence_count on a yearly event rule with days_of_week (#444): on a rule with interval 1 and no days_of_month, the store does not count occurrences, so 6 gave 22 (measured on iCloud). create_event, create_events_batch and update_event refuse a count there, even 1, and write nothing. For those days, use monthly with interval: 12, which holds the count, except that a count from a later occurrence with span future is refused (#443), or end_date. For once a year, use yearly without days_of_week. A yearly rule with days_of_week repeats in the start's month. interval above 1 and a non-empty days_of_month are not refused: the count held. The reminder tools are unchanged: there the count held.
A rule belongs to the series (PR #439): update_event refuses recurrence or clear_recurrence on one occurrence (occurrence_date with span this) and clear_recurrence with occurrence_date and span future, and writes nothing. In every release from v1.5.0 (measured on iCloud on v1.22.0, read at the other tags), a rule saved on one occurrence deleted the rest of the series, clear_recurrence with span future left only one event on the series' first day, and clear_recurrence with span this detached the occurrence as a one-off and left the series repeating. Use span all for the whole series, and span future with occurrence_date to change a rule from that occurrence on. To end a series at an occurrence, pass span all and recurrence with the series' whole rule and an end_date after that occurrence's start, such as 23:59:59 that day: a bare date ends before that day's occurrence, and later occurrences go, also one edited on its own (measured on iCloud). clear_recurrence with span all leaves one event on the first day and deletes the other occurrences. An id of the form <series>/RID=<n>, one occurrence edited on its own, is told the series' id. recurrence with clear_recurrence is refused too.
A failed reminder write may still land (#274): when a commit of an existing reminder or reminder list fails, the change can stay pending in the server's store, and the next save of anything can write it (measured on iCloud, with a failure induced for the test; no ordinary use was found that causes one). A deleted reminder then has no undo, and a deleted list goes with every reminder in it. It cannot be dropped without disturbing other calls, so the error says so, and a batch delete adds failed_rows_note. Reading back right after does not rule it out: what a read shows before the next save was not measured. EventKit refusals 18, 23 and 26 left nothing behind and answer as a plain error. The one exception to the warning is a failed second save of a due date (#237), which answers date_sync.aligned: false with no error, and what may land there is the date asked for. Events and event calendars did not behave this way.
Changing a reminder's recurrence (#354): update_reminder accepts recurrence (the object create_reminder takes, without excluded_occurrence_dates), which replaces the reminder's rule, and clear_recurrence: true, which removes it; the identifier, completion history and alarms stay. A rule change does not move the due date (a default, which can be revisited): measured on iCloud, after a weekly Thursday reminder is changed to Friday its pending occurrence stays on Thursday, and once that is completed the next one is on Friday (measured for a weekly rule on one weekday; for other rules this is expected, not measured). When the due date is not on the new rule's days the response adds recurrence_note; pass due_date to update_reminder to move it. The response reads back has_recurrence, reminder_recurrence_rules and due from the saved reminder after re-reading it (recurrence_read_back: false with recurrence_read_back_note when it cannot be re-read). interval must be 1 to 1000 and occurrence_count 1 to 10000 on every tool that takes recurrence: an interval below 1 or from 2^31 to 2^32, or an occurrence_count of 0, used to crash the server (measured), and the rest of the refused range is refused as unreasonable. Refused before anything is written: recurrence with clear_recurrence or with clear_due_date; a rule on a reminder that will have no due date; a rule on a completed reminder; and clear_due_date on a reminder that keeps repeating (a repeating reminder must have a due date; pass clear_recurrence with it to make it one-off). Undo restores the previous rule (tested in memory, and on a session MCP pending the release gate). How Reminders.app displays the off-rule occurrence was not checked.
Relative alarms on reminders (#333): create_reminder, each row of create_reminders_batch and update_reminder accept alarms_minutes_offsets, minutes before the due date (positive = before, negative = after; integers from -525600 to 525600, one year; the same bound now applies to the event tools), so the stored due date keeps its real time and the notification can come earlier (in the rows measured the list, and the one banner checked, showed the alarm time, see below). Only relative alarms are written: on update_reminder the list replaces the reminder's relative alarms and [] removes them, while absolute-date alarms (#227) and the location trigger (#229) are not changed. This differs from update_event, whose list replaces every alarm. A relative alarm needs a due date with a time: it is refused, with nothing written, when the reminder has no due date (the alarm would never fire) or a date-only one, because when Reminders fires a relative alarm on a day without a time has not been measured on a device (EventKit stores and reads it back; #333 step 0). On a recurring reminder EventKit is expected to move the alarm with each occurrence; that is not yet checked on a device (release gate). What Reminders.app shows (#433): in the 14 rows measured, the list row showed the earliest time-based alarm, relative or absolute, not the due time, so a reminder due 15:30 with [15] read 15:15 and with [-15] read 15:45. That is a fit to those rows, not a documented rule. The stored due date is unchanged (list_reminders reads 15:30). The one notification checked (#333: due 09:54, [2]) came early, and its banner showed the same time as the list row, 09:52, not the due time. No second banner was seen at the due time, but the first was still on screen, so one hidden behind it cannot be excluded. alarms_note says so when the earliest time-based alarm is not at the timed due time, on a call that passes alarms_minutes_offsets, due_date or realign_to_due. A call that touches none of them, such as a title change, gets no note, and neither does a date-only reminder, though #267 measured that the app goes on showing an absolute alarm's time there; so no note does not mean the list shows the due time. Measured list rows: [15], [60], [2], [15, 0], [0], [-15], absolute alarms at or before the due time, and [15] with an absolute alarm at the due time. Every other shape is inferred from them, for example [60, 15], [15, -15], [-15, -60], [1440], an absolute alarm after the due time, [-15] with an absolute alarm at the due time (no note), and [15] or [0] with an earlier one (note). They cannot decide [0, -15]: it gets no note for now, and release gate step 8 looks at it. The rows that showed the due time have no alarm before it, and an extra alarm at the due time did not bring it back ([15] with one read 15:15); firing was not checked on those 14 rows. An Early Reminder set in Reminders.app is different: the one made by hand read back with one absolute alarm at the due time and no alarm before it, so this server cannot write it. Keeping alarms_minutes_offsets is a default and can be revisited. The 14 rows were list rows of one iCloud list; the [60] and [2] rows came from an earlier disposable list (#333). Not checked: the detail panel, other devices, offsets that cross into another day ([1440]), when a [-15] alarm notifies, a past due date, a completed reminder, and whether update_reminder keeps an Early Reminder set in the app. The response lists the relative alarms read back as alarms_minutes_offsets (minutes before), with alarms_differ and alarms_note when they are not the ones asked for (when the display note applies too, the note says both). On a call with alarms_minutes_offsets (create_reminder, a batch row, update_reminder), if the reminder cannot be re-read after the save, or a create's store_differs lists alarm count, the response leaves alarms_minutes_offsets out and sets alarms_read_back: false with alarms_read_back_note. An update_reminder without alarms_minutes_offsets that changes the due date (due_date or clear_due_date) and leaves the saved reminder date-only or without a due date leaves its relative alarms in place and adds alarms_note: how many are left, that without a due date they never fire or that on a date-only reminder their firing has not been measured, and that alarms_minutes_offsets: [] removes them (after clear_due_date the count is taken from the saved object, not re-read; a due_date whose re-read failed gets no such note, and date_sync.aligned is false); a skipped duplicate create gets alarms_applied: false. Undo of the update restores the previous alarms, location alarm included. Tested in memory: the restore, and the #236 guard comparing alarms by value (it names alarms when one changed elsewhere); what a synced store reads back is pending the release gate.
Rows of create_reminders_batch are checked (#441): a row with any key other than title, notes, due_date, priority, calendar_name, calendar_source, tags and alarms_minutes_offsets fails, with nothing written for it, and the other rows go on. Such a key used to be ignored, so recurrence on a row made a reminder that does not repeat. recurrence and location_trigger need create_reminder.
Date-only reminders (#267): a due_date given as a bare date (2026-10-18) to create_reminder, create_reminders_batch or update_reminder makes a date-only reminder, a date with no time, as Reminders.app makes one; read back, its due.time is null. On update_reminder the start date is set to that day, also one set apart from the due date, and absolute-date alarms are removed, because Reminders.app displays the earliest one and would go on showing its time (checked on device); relative and location alarms are kept, and date_sync.absolute_alarms_removed counts what went. Giving a timed reminder its own date removes the time. A due date with a time works as before. A bare-date create_reminder (and each created row of create_reminders_batch) reads the saved reminder back and returns date_sync, as update_reminder does: date_sync.aligned is false when the due came back with a time, came back missing, or the reminder could not be re-read (#301). A written due that reads back missing also gives aligned: false on update_reminder, where aligned used to be left out. In list_reminders, a date-only reminder is overdue only once its day has ended in the host time zone, and in list_reminders and search_reminders its due_date / due_date_local read as 00:00 of that day in the host time zone (#297).
A reminder's own time zone (#241): list_reminders and search_reminders report it as item_timezone, a time zone identifier (usually IANA), or null when the reminder stores no time zone: a floating timed reminder, a date-only reminder, or one without a due date (a floating timed reminder is item_timezone null with due.time set). The per-reminder timezone is the host time zone, the zone of the *_local strings, not the reminder's.
Tool | Description |
| Search events by keyword(s) with AND/OR matching |
| Quick shortcuts: |
| Create multiple events at once (with per-event timezone) |
| Check for overlapping events in a time range |
| Copy an event to another calendar; |
| Move events to another calendar in place, keeping recurrence and attendees; |
| Delete events by IDs or date range, with dry-run preview (v1.0.0) |
| Find duplicate events across calendars (v0.5.0) |
| Create multiple reminders at once (v0.9.0); a row may carry |
| Delete multiple reminders at once (v0.9.0); one |
Moving events (#226): a move reassigns the event's calendar, so recurrence, attendees and every other field are kept. The identifier stayed the same within an iCloud account; within a Google account it changed, and so does it across accounts, so read id_changed and new_event_id in each result (#435). When it changes, older undo records of that event follow it, and the undo of the move reports the new one. For a recurring event, span: this (default) moves only the occurrence named in occurrence_dates, which becomes a one-off; span: all moves the whole series; a recurring event without a date is refused. If an in-place move fails, the event is copied and the original removed, unless it has recurrence or attendees. Each result reports method (in_place / copied / split / unchanged) and any not_carried_over fields. Undo of an in-place move moves the event back; undo of a split or a copy restores the original in its calendar (a split occurrence as a one-off event; if its series (or one an undo recreated from it, matched by identifier, #321) already holds its original slot there, nothing is recreated and the copy is not looked up or removed; for an occurrence edited on its own, the copy is then the only version with that edit) and then removes the copy, but only when the copy is found under its identifier and unchanged since the move (#466). A copy changed since (in any field the #236 check compares, a change a sync makes on its own included), with attendees, not found (deleted, or given a new identifier), recorded without an identifier, not checkable, or whose removal failed is not removed, and the answer says so. It says the event is in both calendars only where the copy is known to be in the calendar it was moved to (its removal failed, it has attendees, or it changed in fields other than its calendar), and otherwise that it may be; removing the copy is up to the user. The restore of the original is never refused because of the copy.
Identifier after an update (#268): update_event and update_reminder answer with the identifier the item has after the save. It changes in three cases only: a calendar_name in another account (the event part follows the #226 device finding; an edit of one occurrence with a new calendar is refused, since it would move the whole series), update_event with span: "future" from a later occurrence (the new series' identifier, also through the identifier of an occurrence edited on its own), and span all or future with the identifier of an occurrence edited on its own that is the first occurrence left after the first was deleted (what remains gets a new identifier). id_changed says whether it differs from the identifier you passed, and previous_id, present only when it changed, repeats that identifier. An edit of one occurrence (occurrence_date, span this) answers the series' identifier as id and adds occurrence_id, the identifier of that occurrence alone.
Writes through an occurrence's own identifier (#416): an occurrence edited on its own has its own identifier (on iCloud, the series identifier followed by /RID= and a number). What a span does through it depends on where the occurrence is:
Span
thisedits or deletes that occurrence. It takes nocalendar_namethat changes the calendar,recurrenceorclear_recurrence: those are refused, as they reach the series. The same goes for a newcalendar_namethrough the series' identifier withoccurrence_dateand spanthis(#426).update_eventwith spanallorfuturecan writetitle,notes,locationandrecurrencethrough it (notrecurrencewith spanallafter the series' first occurrence was deleted). It can also writestart_time,end_time,all_day: true, alarms,timezone,urlandstructured_location(#428), but only in the shapes measured on iCloud, all of these at once: a timed series repeating weekly, every week, on at most one day; one of these fields per call, orstart_timewithend_time, and nothing else in the call;start_timeandend_timeonly as a new time of day, the start and the end staying on their dates; the occurrence still at its original start and differing from an unedited occurrence only in title, notes, location or alarms; alarms only while it has the series' alarms; andtimezone,urlandstructured_locationonly in a series that ends after a number of occurrences, whose first occurrence is still there and which has no other occurrence edited on its own; with spanfutureevery one of these fields needs that series. On a series that has started, these writes take the started-series rules (#451), through the occurrence's own identifier and through the series' identifier alike (measured on iCloud after #471 and #478); atimezonefrom an occurrence that has happened is refused by #478's check.all_day: truefrom the first occurrence left, after the first was deleted, is refused. After a date was written through it, its/RID=stays at the old slot (measured), so later dates through it are refused.all_day: trueonly with spanall, while the series' first occurrence is still there (with spanfuture#451 refuses it, and so does this check).all_day: truehas no way back through it:all_day: falseis refused, and so are dates on an all-day series. Anything else is refused and nothing is written, even a field that changes nothing, such asall_day: false. For the rest, use spanthisor the series' identifier, as below.update_eventspanfutureupdates from that occurrence on. From a later occurrence it ends the series there and starts a new one, which can resend invitations;id_changedreports it.update_eventspanallupdates every occurrence when it is the first one left. With an earlier occurrence left it would split the series, so it is refused and writes nothing: use theidthatlist_eventsshows for the occurrence that comes first in the series, by its original date. A series whose first occurrence was deleted has no whole-series update unless its first occurrence left was edited on its own (#326); what remains is spanfuturefrom a later occurrence, or spanthison each.A value the occurrence already holds is refused with both spans: it reaches no other occurrence, though the call would answer
updated(measured). Leave the field out, or use the series' identifier.A write that starts a new series (span
futurefrom a later occurrence, or either span from the first one left after the first was deleted) is refused while the occurrence holds its own location, alarms, time zone, all-day flag, availability or coordinates, because the new series took its location and alarms (measured). Writinglocationreplaces a location given as text.days_of_weekin a newrecurrenceis checked against the occurrence's original day, so from an occurrence moved to another weekday a rule on the day it shows is refused. With spanfuturefrom a later occurrence,occurrence_countis refused, through the series' identifier too, because the store counts from the original first start (count 6 from the third occurrence gave 4, #443); useend_date.The series' identifier is the
idthatlist_eventsshows for an occurrence that was not edited on its own. With spanallit took the other fields, with an edited occurrence in the series. Occurrences edited on their own keep what was edited, so use spanthison them afterwards.clear_recurrencethere leaves one event at the series' first date, without the edited occurrences.all_daychanging the flag there (or with spanfuturefrom the first occurrence) is refused while the series has an edited occurrence, since it brought that occurrence's original slot back as an extra occurrence; it is also refused when such an occurrence cannot be ruled out, because the scan cannot show where the series ends (no end, an end past about five years from its start, or a count whose last occurrence cannot be worked out) or cannot list it, and it goes ahead when the same call clears the rule (#442);clear_timezoneis refused on a series with a count, as it dropped the last occurrence, with or without an edited occurrence (#455), and a calendar of another account was not run. For a calendar of the same account,move_events_batchspanallmoves the whole series. For spanfuture, use spanthison this occurrence, then thatidwith the next occurrence'soccurrence_dateand spanfuture; that leaves outall_day,clear_recurrenceandcalendar_name, which do not stay within the later occurrences (#451), andclear_timezone, which was not run.The series' identifier with the first occurrence edited (#428): when the first occurrence was edited on its own and still starts at its original time, or was deleted and the first one left was edited on its own, the series has no object to save.
update_eventanddelete_eventwith spanallthrough the series' identifier then go through that occurrence's own identifier, under the rules above, while that occurrence does not start after the next occurrence's slot or another occurrence's shown start. When it does, its own identifier takes no spanallorfuturewrite and no spanfuturedelete either: that save was not measured. After a deleted first occurrence a newrecurrencewith spanallis refused, through the series' identifier or that occurrence's own: it would start a new series from a later slot, which was not measured with a rule.idstays the series' identifier unless a new series starts, andoccurrence_idnames the occurrence. The delete removes every occurrence (measured on iCloud with series of 5, open-ended series and further edited occurrences, listed over seven years afterwards); its response addsoccurrence_idandundo_note, as it cannot be undone. Spanfuturethrough the series' identifier is still refused in that shape; the first occurrence's ownidtakesdelete_eventspanfuture.delete_events_batchwith spanall, byevent_idsor by date range, deletes the series' row through that occurrence too, and its dry run says so (#436, #427). That delete cannot be undone: the dry-run row givesoccurrence_idand anundo_note; the real call lists it underthrough_first_occurrencewith anundo_noteand leaves it out of the call's undo, which restores the other rows, or, when no other row was deleted, is refused and dropped asdelete_event's is.Calls these refusals took away (#416):
start_timealone with spanallorfuturefrom an occurrence moved by a time, keeping the series' length, did what it says. It stays refused, because that occurrence has a time of its own; use the series' identifier. Dates on the only occurrence left are always refused, as there is no unedited occurrence to compare it with, and so are dates when every remaining occurrence was edited on its own; spanthisdoes the same there.With span
thisorfuture,occurrence_datemust be that occurrence's day, or the call is refused: it was ignored, and the write started at the occurrence the identifier names.delete_eventspanfuturedeletes that occurrence and the later ones, so from the first occurrence it deletes the whole series.delete_eventspanalldeletes that occurrence only, so it is refused and deletes nothing while another occurrence remains. To delete the whole series, use theidof the occurrence that comes first in the series: with spanallwhen it has no/RID=part, with spanfuturewhen it has one (that delete cannot be undone). A series that may reach past the scan, about five years, counts as having other occurrences.delete_events_batchbyevent_idsrefuses that row, in the dry run too, and goes on with the others; occurrences named in the same call do not count as remaining, and with spanalltheir rows run first, in the dry run too. By date range, with spanall, the row is refused the same way while another occurrence of its series is not listed, in the dry run too, which does not count it indeletions(#427). When the series' own identifier is listed too and its row deletes on its own, an occurrence whose id has a/RID=part is left to that row, which deletes the series with it, also for a series that may reach past the scan; otherwise the occurrence's row runs first, as byevent_ids, and the series' row then reaches the rest.move_events_batchspanallmoved the whole series and kept the identifier, within one iCloud account, and undo moved it back; across accounts was not measured. Spanthisneeds that occurrence's own day inoccurrence_dates, and copies it to the target as a single event, removing it from the series; without a date, or with another day, it is refused and nothing moves (#426).copy_eventwithdelete_originalrefuses such an identifier, as it does a recurring event.
This was measured on iCloud. The whole-series update and delete above go through the occurrence that comes first, which the series' identifier reaches too in those shapes (#428). On Google, #320 measured /RID= and isDetached as reliable, but these refusals did not run there. A source with no /RID=, which Exchange may be (not measured), cannot list the rest of the series from the identifier, so span all, and update_event span future, are refused there, also for a single event it wrongly reports as an edited occurrence; use span this. On iCloud an occurrence past the scan, in a series that reaches more than about five years from its start, is refused the same way, and the series' identifier takes the whole-series update and delete (measured on a series started about seven years ago).
All-day dates with an offset (#372): all-day dates are whole days read in this Mac's time zone. A start_time or end_time written with another offset is converted first and can land on another day (2027-04-11T23:59:59+07:00 on a +08:00 Mac is 04-12, so the event gets one more day). The event is still saved that way; create_event, each create_events_batch result and update_event (when the event is all-day after the call, for the dates it gave) add a warnings list whose entries name the field, its day in the written offset (written_day), the day in this Mac's zone the request's dates are written with (host_day; a skipped duplicate writes nothing, and its message says the existing event was not changed) and a message. The comparison is on the days, so an offset whose day agrees, or a value without an offset, gives no warning. Send all-day dates without an offset.
Alarms on a Google calendar (#317): measured on a calendar hosted by Google. About a minute after the save, an absolute alarm reads back as a relative alarm at the same time, and a location alarm that copy_event, or the undo of delete_event on a one-off event, writes becomes a plain alarm at the event's start; the response cannot see either. Relative alarms were kept on every path, including alarms_minutes_offsets written by update_event on a one-off event. Google never listed an email alarm, on any event measured, nor the location alarm of the event that was not copied, though this Mac still reads both; reading an event back shows this Mac's view only, so it cannot show what Google holds. The absolute-alarm change and the email alarm held for an event this server did not write too. Not measured: Exchange and other accounts, copy_event with delete_original, the fallback copy and cross-account move of move_events_batch, create_events_batch, the undo of a series, an occurrence or a batch, sound alarms.
Tool | Description |
| Undo the most recent recorded calendar/reminder operation. |
| Redo the last undone operation. Only completions are written again; for a create, delete, update or move, redo writes nothing, answers |
| List undoable operations with timestamps |
Undo does not overwrite a change a person made since (#236): an undo of a create, update, move or completion first re-reads the event or reminder and checks that nothing the undo would overwrite or delete was changed since that operation (for a move, the calendar; for a recurring event created here, also occurrences edited on their own, looked for up to four years from the first occurrence; an occurrence counts when it differs from the series in any compared field, alarms, time zone, place and availability included, and one back at the series' values does not; availability counts only when both the occurrence and the series report one, so on a store that reports it on one side only an occurrence's own availability is not seen). A field already changed back to the value the undo writes does not count. Two changes a calendar app or server makes on its own are not treated as edits, so they never block an undo and an update-undo overwrites them: an alarm sound, and coordinates added to a place that had none. If something was changed, nothing is written, the error names the changed fields, and the entry stays in undo_history; whoever made the change should decide whether to change it back and undo again, or to give up that undo with undo + discard_id. Three create-undo refusals leave only the choice whether to discard: occurrences edited on their own, which cannot be put back into the series; a series whose occurrences could not be checked (unchecked_occurrences), which cannot be checked later either; and a series whose rule was shortened (recurrence; an update or delete of an occurrence and the following ones does this), whose cut-off part cannot be put back. Redo of a completion makes the same check against the state the undo left. An update_event that touched a recurring event (one occurrence, span "future" or "all", or rules added or removed) cannot be undone: undo on it writes nothing, says to revert the change in Calendar if it should be reverted, and drops that entry so earlier ones stay undoable (#262: such an undo used to delete the rest of a series). The undo of an earlier update or move of a one-off event is refused the same way if the event repeats or is an edited occurrence by the time of the undo (a move of an already edited occurrence included), so update-undo and one-off move-undo never write to a recurring event (a series move-undo still moves the whole series back, by design). A safe restore for recurring updates is #263.
Keeping later edits when undoing an update (#265): when the undo of an update_event on a one-off event, or of an update_reminder, is refused because the item changed after the update, the error now lists the fields the update changed and the fields changed after it separately. When none of the later edits is in what the update changed, it says so; undo with keep_later_edits: true (ask the user first) then restores only the fields the update changed and keeps the later edits, and its answer names both. Fields that move together count as one: an event's start, end, all-day flag, time zone, alarms, calendar and availability (a relative alarm means another time once the event turns all-day or timed, and the calendar decides which alarms and availability it keeps); its location and place (coordinates the update added count as its change); a reminder's due date, start date, alarms, repetition, completion and list. An update that changed none of the fields undo restores is not offered the merge. A later edit inside what the update changed refuses as before, and so does any field outside these groups. Without the flag nothing changes: undo restores every recorded field and refuses on any later edit. Any other undo runs as without it, an update that touched a recurring event stays refused (#263), and the flag cannot be combined with discard_id or restore_available. Only unit tests and source pins cover the merge; the write was not run on a device (undo is stateful, so it waits for the release gate on the session MCP).
A series whose first occurrence was deleted (#326, #335): its id still works. The tools reach it through its earliest remaining occurrence, looked for from a day before the series' first start up to 1460 days on, in the series' calendar: a move, a copy, a delete and an update of a single occurrence (occurrence_date, span this) work, and undo of those moves and deletes restores the series. A whole-series update, or a delete or update from a date on, is refused with an explanation when an occurrence edited on its own comes before the remaining ones. If every remaining occurrence starts more than 1460 days after the series' first start, none is found and the id answers Event not found: that cannot be told apart from a series with nothing left without a wider search, so change such a series in Calendar.
Undo of deleted occurrences and availability (#244, #245): undo of a delete_event that removed one occurrence of a recurring event (span "this") brings that occurrence back as a one-off event at its time, not into its series (EventKit cannot put it back), and an absolute-date alarm of the series becomes an alarm at its start (the undo text names it, once for a batch). Undo of a span "future" delete recreates the series only when the delete started at the series' first occurrence and removed all of it; otherwise (part of the series is left, the delete started at the last occurrence, or the id named an edited occurrence) it writes nothing, says to restore the occurrences in Calendar, and drops that entry. A delete_events_batch holding such a delete is refused before any of its events is restored. Undo of a whole-series delete (that span "future" case, or span "all") recreates the series from its rules and then restores its exceptions inside the checked window (at most four years: from three years before the delete, or from one day before the first occurrence when that is later, moved earlier for a rule that ends sooner, a count of occurrences included, so a series that began long ago is checked around the time of the delete and one begun within about three years keeps the window from its first occurrence, #319): an occurrence deleted on its own earlier stays deleted, and one edited on its own comes back as a separate one-off event that no longer belongs to the series (#285); the undo text says how many of each. Occurrences are listed a year beyond each window edge and judged by their rule slot, so an edited occurrence moved out of the window still comes back as a one-off (one moved more than a year past it is not restored and its slot counts as deleted; if the record does not match the recreated series, no exception is applied and the text says so). The series' first occurrence is never removed: if it was edited or deleted on its own it is kept from the rule and the text says so. Exceptions outside that window, and those of a series that could not be listed when it was deleted, come back from the rules alone, which the text also says. Undoing the earlier delete of an occurrence that the restored series already holds writes nothing and says so; the occurrence is matched by the series it belonged to (or the series an undo recreated from it) and the slot the rule gave it, not by title (#321), so an unrelated series with the same title at that time does not stop it from coming back, and a record that cannot say its series writes the occurrence and says so. The same check covers an occurrence edited on its own that was deleted by its own id beside its series. undo_history lists such a record as Deleted event: <title> (undo recreates the series and its exceptions within the checked window (from <start> up to <end>): …) (or … within the checked windows (from … up to …, and from … up to …): …) for a count whose window around the delete starts after its first occurrence) and a delete_events_batch record holding one as Batch (<N> operations; undo recreates <K> series and their exceptions within the checked window or windows: …) (a batch whose undo is refused says undo not available instead). Checked on iCloud with delete_event (span "future") and delete_events_batch (span "all"). Delete-undo now keeps an event's availability (free, busy…) where its calendar supports the value, and the create and update checks above compare it.
Batch deletes undo as one entry (#185, #243, #248): delete_events_batch, a series delete, delete_reminders_batch and cleanup_completed_reminders each record one undo entry holding the items the call actually removed, and it becomes the newest entry. One undo recreates them one save at a time within that single call (a cleanup holds up to its limit, 1000 by default, or every reminder_ids entry), each under a new identifier and with what the undo snapshot records; a reminder's subtasks are not recorded. Before the first write, the calendar or list each item goes back into is checked, by its recorded identifier as the restore looks it up (a same-named list in another account does not count); if one is missing or read-only (read again after a refresh is requested), nothing is written, the entry stays whole, and the error says how many items have their calendar or list in place; giving up with discard_id drops them all. With restore_available: true (ask the user first, #287), undo instead restores the items whose calendar or list is in place and keeps an entry of only the others, under the same id; the answer is success: true with partial: true and kept, says which kinds of calendar or list stop the kept items (naming the items, not the calendar, list or account), and a later undo restores them once their calendar or list is back, while discard_id then drops only them. When no item can be restored the refusal stands, and the argument changes nothing for any other undo. If a save still fails, the entry keeps only the items not yet restored, under the same id, with the failing one moved to run last, so the next undo restores the others first, unless the failing one's calendar or list is by then missing or read-only, which the check above refuses. Each such call clears the redo history, and only the newest 50 undo entries are kept.
Batch delete by event_ids with span this or future (#452): the rows run in the given order, and the dry run runs them the same way, with one exception below. An id repeated after a row that deleted it, or after a row the earlier delete took, fails with Event not found: an earlier row of this call deleted it, in the dry run too. With span future the id of an occurrence edited on its own (it contains /RID=<slot>) deletes that occurrence and the later occurrences of its series. Its dry-run row says so with also_deletes_later_occurrences: true and an undo_note, and the real call lists it under following_occurrences_deleted with an undo_note: such a delete cannot be undone and makes the call's undo refuse and drop its record, so none of the call's rows comes back. Every row is looked up first. The exception: the id of a later occurrence of that series that resolves and that the earlier delete reaches is shown in the dry run as covered_by_earlier_row: true, with its dates, and counted in covered_by_earlier_rows, not in already_removed, because the dry run cannot see that delete. The real call counts it in already_removed when its lookup no longer finds it, taken as removed with the series (iCloud removed it, 2026-11-09), or, when the store kept it, deletes it as a row of its own; that row is then listed under following_occurrences_deleted too, which its dry-run row does not show. So, when no row of the real call fails for another reason (a lookup that raises is one), the dry run's rows without an error equal the real call's succeeded plus already_removed. This was checked for occurrences edited in place; a deleting occurrence moved before a later row's shown start was not. A /RID= slot before 2001 is a negative number, which is not read, so such a delete covers no later row: that row is looked up and, when it is gone, fails as not found. An occurrence id that never resolved, or that resolves to an event with rules, fails in both. A bare recurring id needs an occurrence date and fails in both; after a row deleted its series from the first occurrence, the dry run refuses it and the real call does not find it. Measured on iCloud with --cli (2026-11-09): a repeated one-off, two occurrences' own ids of a weekly series in either order and from the first occurrence, and an occurrence's own id that never existed; other sources were not measured. also_deletes_later_occurrences, following_occurrences_deleted and their undo_notes are read from /RID= in the id, an iCloud form (#416), while the undo refusal follows the store's isDetached.
Installation
The quick paths live at the top of this README. This is the full reference — manual config, source builds, permission edge cases, in-place upgrades, and CLI mode.
Requirements
macOS 14.0+ (Sonoma or later — required since v1.11.0 for the full TCC permission API)
Xcode Command Line Tools (only if building from source)
Claude Desktop
One-click (recommended): download the latest che-ical-mcp-<version>.mcpb from Releases, double-click, and restart Claude Desktop.
Manual config: download the binary, then point claude_desktop_config.json at it.
curl -L https://github.com/PsychQuant/che-ical-mcp/releases/latest/download/CheICalMCP -o /usr/local/bin/che-ical-mcp
chmod +x /usr/local/bin/che-ical-mcpEdit ~/Library/Application Support/Claude/claude_desktop_config.json, then restart Claude Desktop:
{
"mcpServers": {
"che-ical-mcp": {
"command": "/usr/local/bin/che-ical-mcp"
}
}
}Claude Code — plugin (recommended)
claude plugin marketplace add PsychQuant/che-ical-mcp
claude plugin install che-ical-mcp@che-ical-mcpInside Claude Code, the slash equivalents
/plugin marketplace add PsychQuant/che-ical-mcpand/plugin install che-ical-mcp@che-ical-mcpwork the same way.Add the marketplace through its Git repo (
owner/repo), not a rawmarketplace.jsonURL — the pluginsourceis a same-repo relative path (./plugin) that only resolves when added via Git.Also bundled in the
psychquant-claude-pluginsaggregator (claude plugin install che-ical-mcp@psychquant-claude-plugins); both serve the same versioned binary.The wrapper auto-downloads the binary to
~/bin/CheICalMCPon first use if it isn't already there.
Claude Code — standalone MCP
mkdir -p ~/bin
# If upgrading, remove the old binary first. On macOS 26 the kernel can kill a
# fresh binary that inherits a stale code-signature cache from the old inode —
# one a running MCP process may still be holding open.
rm -f ~/bin/CheICalMCP
curl -L https://github.com/PsychQuant/che-ical-mcp/releases/latest/download/CheICalMCP -o ~/bin/CheICalMCP
chmod +x ~/bin/CheICalMCP
# --scope user: available in all projects · --transport stdio: local stdin/stdout
claude mcp add --scope user --transport stdio che-ical-mcp -- ~/bin/CheICalMCP💡 Tip: Keep the binary in a local directory like
~/bin/. Cloud-synced folders (Dropbox, iCloud, OneDrive) can trigger MCP connection timeouts when sync touches the file.
Build from Source (optional)
git clone https://github.com/PsychQuant/che-ical-mcp.git
cd che-ical-mcp
make release && make install
claude mcp add --scope user --transport stdio che-ical-mcp -- ~/bin/CheICalMCP⚠️ Swift 6 / Xcode 18 users: Don't run
swift builddirectly — the upstream MCP SDK has a concurrency error (swift-sdk#214). The Makefile auto-detects this and falls back to Swift 5 language mode.
Grant Permissions
On first use, macOS will prompt for Calendar and Reminders access. Click Allow for both.
⚠️ macOS Sequoia (15.x) Note: The permission dialog is attributed to the parent application that launched the MCP server, not the binary itself. This means:
Environment
Permission Attributed To
Claude Desktop
Claude Desktop.app ✅ (works automatically)
Claude Code in Terminal.app
Terminal.app ✅ (works automatically)
Claude Code in VS Code
VS Code ❌ (may not show dialog)
Claude Code in iTerm2
iTerm2 ✅ (works automatically)
If the permission dialog doesn't appear (common with VS Code), you need to add
NSCalendarsFullAccessUsageDescriptionto VS Code's Info.plist:# Add calendar usage description to VS Code /usr/libexec/PlistBuddy -c "Add :NSCalendarsFullAccessUsageDescription string 'VS Code needs calendar access for MCP extensions.'" \ "/Applications/Visual Studio Code.app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Add :NSRemindersFullAccessUsageDescription string 'VS Code needs reminders access for MCP extensions.'" \ "/Applications/Visual Studio Code.app/Contents/Info.plist" # Re-sign VS Code (required after Info.plist modification) codesign -s - -f --deep "/Applications/Visual Studio Code.app" # Restart VS Code, then the permission dialog will appearNote: This modification will be overwritten when VS Code updates. You'll need to re-apply it after each VS Code update.
Upgrading an existing install
The plugin wrapper auto-downloads on fresh installs but does not replace an existing binary. To upgrade in place:
~/bin/CheICalMCP --self-updateThis queries GitHub Releases for the latest tag, downloads the new binary, checks it against the published SHA-256, the maintainer's Developer ID signature and Apple notarization, and only then atomically replaces the current one (any failed check leaves your install untouched). If it is running as an MCP server, restart your MCP host (Claude Desktop / Claude Code) afterward to pick up the new version. Manual fallback: rm -f ~/bin/CheICalMCP && curl -L https://github.com/PsychQuant/che-ical-mcp/releases/latest/download/CheICalMCP -o ~/bin/CheICalMCP && chmod +x ~/bin/CheICalMCP.
CLI Mode (no MCP server)
All 29 tools can be invoked directly from the command line, no MCP server required:
# Flag-based: --key value pairs
CheICalMCP --cli list_events --start_date 2026-03-29 --end_date 2026-03-30
# JSON via stdin
echo '{"tool":"list_calendars","arguments":{}}' | CheICalMCP --cli
# From Claude Code via shell
claude -p "Run: ~/bin/CheICalMCP --cli list_events_quick --range today"Handy for launchd jobs, shell scripts, CI pipelines, and agents that prefer a subprocess over the MCP protocol. TCC permissions still apply — run CheICalMCP --setup first if needed: from v1.23.0 a context without a Calendar and Reminders grant of its own uses the grant --setup creates (#343).
v1.0.0 Features
Flexible Date Parsing
All date parameters now accept 4 formats:
Format | Example | Interpretation |
Full ISO8601 |
| Exact date and time (offset preserved) |
Without timezone |
| Uses event |
Date only |
| Midnight in event |
Time only |
| Today at that time |
Per-Event Timezone (v1.5.0)
Set the display timezone for individual events — essential for multi-timezone travel itineraries.
"Create a flight departure at 09:14 Berlin time"
→ create_event(title: "Flight LH123", start_time: "2026-04-08T09:14:00", timezone: "Europe/Berlin", ...)
"Update the hotel check-in to Dubai time"
→ update_event(event_id: "...", timezone: "Asia/Dubai")
"Remove the custom timezone from an event"
→ update_event(event_id: "...", clear_timezone: true)timezoneparameter accepts IANA identifiers (e.g.,Europe/Berlin,America/New_York,Asia/Taipei)When
timezoneis provided, naive datetimes (without offset) are interpreted in that timezoneexcept
update_event'soccurrence_date, which names an occurrence of the event as it is now: without an offset it is read in the event's current time zone, as ondelete_eventandmove_events_batch(#383, #270)with an offset it is used as given, and its day is taken in the event's current time zone; the response's
occurrence_startgives the start of the occurrence it matched
Event output includes the event's own timezone in
timezonefield and formatsstart_date_local/end_date_localaccordinglyEvent output also reports
item_timezone(#386): the event's own time zone identifier (usually IANA), ornullwhen the event stores no time zone (a floating event; all-day events are floating).timezonefalls back to the host time zone for those, soitem_timezoneis what tells them apart from an event stored in the host zone. Withdisplay_timezone, the*_localstrings are in that zone; neither field changesAvailable on
create_event,update_event, andcreate_events_batchUndo/redo preserves per-event timezone
Alarms (Read)
Event responses list the event's time-based alarms as alarms (#240), in the shape the reminder read tools use (#231), so a caller can check what alarms_minutes_offsets wrote and see absolute-date alarms. Available in list_events, search_events and list_events_quick (all three outside detail_level: summary; fields: ["alarms"] selects it), and in check_conflicts.
{"kind": "relative", "minutes_before": 15}: measured from the start, positive = before, negative = after (the sense ofalarms_minutes_offsets){"kind": "absolute", "absolute_date": "...Z", "absolute_date_local": "..."}:absolute_date_localis in the zone of the other*_localstrings (display_timezonewhen given)Absolute alarms come first by date, then relative alarms earliest first;
[]when none. Location alarms are not listed. An occurrence reports the series' alarms unless it was edited on its own (then its own), as EventKit returns them
Attendees & Organizer (Read-Only)
Event responses include participant information when available. These fields are read-only due to EventKit limitations — they cannot be set or modified through the MCP.
Available in: list_events, search_events, list_events_quick, check_conflicts
attendees (array, optional) — Present when the event has participants. Each attendee object contains:
Field | Type | Description |
| string or null | Display name, null if not in Address Book |
| string | Email address extracted from participant URL |
| string | One of: |
| string | One of: |
| string | One of: |
| boolean | Whether this participant is the current user |
organizer (object, optional) — Present when the event has an organizer. Contains:
Field | Type | Description |
| string or null | Display name |
| string | Email address |
| boolean | Whether the organizer is the current user |
Note: Both fields are omitted when the event has no participants or organizer (e.g., local calendar events created without invitees).
Fuzzy Calendar Matching
Calendar names are now matched case-insensitively. If not found, the error says Calendar not found: <name>, or Calendar '<name>' not found in source '<source>' with calendar_source; list_calendars shows the names.
Enhanced list/delete Tools
list_events:filter(all/past/future/all_day),sort(asc/desc),limitlist_reminders:filter(all/incomplete/completed/overdue),sort(due_date/creation_date/priority/title),limitdelete_events_batch: date range mode (before_date/after_date) +dry_runpreview
find_duplicate_events checks calendar_names (#437): names are matched as the other tools match them: the exact title, or ignoring case when no title matches exactly. A name that matches no calendar, fewer than two calendars, or a value that is not an array of strings is refused and nothing is returned, because it only compares different calendars. With no names it compares every calendar, so a host with fewer than two is refused too. A name two accounts share reads both; this tool has no calendar_source, and calendar_name, calendar_source and calendars are refused rather than ignored. calendars_checked lists the calendars read, with the account where two share a title (Work (iCloud), which is a label: pass titles from list_calendars); with no names that is every calendar, not all calendars. Each side of a pair gives calendar and calendar_source, which tells two accounts apart; two calendars of one title in one account read the same on both sides. Two accounts sharing a name have been tested in unit tests only, and the read with no names has not been run on a device.
Reads over a wide range (#332): a range of at most 1462 days is read in full, whatever it holds; four calendar years across a clock change are 1461 days and an hour, so they count as narrow. Over a longer range, list_events, search_events, check_conflicts, find_duplicate_events and delete_events_batch by date range stop with an error, returning nothing and deleting nothing, once the read has passed about 200,000 occurrences. All calendars read are counted together, and on search_events the count comes before keywords are matched. This is a safety valve, not an everyday limit: over 100 years it is about 2,000 occurrences a year (between five and six open-ended daily series, or about 40 weekly ones), and over the 400-year ceiling about 500 a year. The count is checked after each 1460-day part, which EventKit reads whole, so a read can pass it by that part's size. limit does not lift it. Narrow the range, or name one calendar; on find_duplicate_events naming fewer calendars also reads less, but keep at least two, because it only compares different calendars; on check_conflicts other calendars are then not checked; on delete_events_batch give both after_date and before_date, at most 1460 days apart.
Over a range longer than 1462 days, without limit at most 500 events are returned, with default_limit (metadata.default_limit on list_events). search_events with only start_date or only end_date keeps two years from today on the other side, so such a range is often this long. limit takes up to 10000; more rows than that need a narrower range. On any range, event_count is still the total and truncated: true appears when fewer events are returned than event_count, whether the default or your limit cut them. What a cut keeps differs by tool: list_events keeps the first in its sort; search_events, which has no sort, keeps the events closest to today, returns them sorted by start and says so in truncation_note. With nothing cut, search_events returns events in the order they were read, which is not date order.
Breaking Change:
list_eventsandlist_remindersnow return{events/reminders: [...], metadata: {...}}instead of a plain array.
Usage Examples
Calendar Management
"List all my calendars"
"What's on my schedule next week?"
"Create a meeting tomorrow at 2 PM titled 'Team Sync'"
"Add a dentist appointment on Friday at 10 AM with location '123 Main St'"
"Delete the meeting called 'Cancelled Meeting'"Reminder Management
"List my incomplete reminders"
"Show all reminders in my Shopping list"
"Add a reminder: Buy milk"
"Create a reminder to call mom tomorrow at 5 PM"
"Mark 'Buy milk' as completed"
"Delete the reminder about groceries"Reminder Management (v1.5.0)
"Remove the due date from 'Buy groceries'"
→ update_reminder(reminder_id: "...", clear_due_date: true)Advanced Features (v0.3.0+)
"Search for events containing 'meeting'"
"Search for events with both 'project' AND 'review'"
"What do I have today?"
"Show me this week's schedule"
"Are there any conflicts if I schedule a meeting from 2-3 PM?"
"Create 3 weekly team meetings for the next 3 weeks"
"Copy the dentist appointment to my Work calendar"
"Move all events from 'Old Calendar' to 'New Calendar'"
"Delete all the cancelled events"
"Find duplicate events between 'IDOL' and 'Idol' calendars"DX Improvements (v1.0.0)
"Show my next 5 upcoming events"
→ list_events(start_date: "2026-02-06", end_date: "2026-12-31", filter: "future", sort: "asc", limit: 5)
"Show my overdue reminders"
→ list_reminders(filter: "overdue")
"Preview which events would be deleted from 'Old Calendar' before 2025"
→ delete_events_batch(calendar_name: "Old Calendar", before_date: "2025-01-01", dry_run: true)
"Create an event at 2 PM" (no need for full ISO8601!)
→ create_event(start_time: "14:00", end_time: "15:00", ...)Supported Calendar Sources
Works with any calendar synced to macOS Calendar app:
iCloud Calendar
Google Calendar
Microsoft Outlook/Exchange
CalDAV calendars
Local calendars
Same-Name Calendar Disambiguation (v0.6.0+)
If you have calendars with the same name from different sources (e.g., "Work" in both iCloud and Google), use the calendar_source parameter:
"Create an event in my iCloud Work calendar"
→ create_event(calendar_name: "Work", calendar_source: "iCloud", ...)
"Show events from my Google Work calendar"
→ list_events(calendar_name: "Work", calendar_source: "Google", ...)If ambiguity is detected, the error says several calendars have that name and asks for calendar_source; list_calendars shows the sources.
Troubleshooting
Problem | Solution |
Server disconnected | Rebuild with |
Permission denied | Grant Calendar/Reminders access in System Settings > Privacy & Security |
Permission dialog never appears | See Grant Permissions for macOS Sequoia workaround |
Permission denied over SSH | See SSH Access below |
Permission denied under launchd | See launchd / Automation below |
One service denied while every diagnostic reports green | See Silent permanent denial after upgrade below |
Calendar/Reminders break again after every Claude Code update | |
Calendar not found | Ensure the calendar is visible in macOS Calendar app |
Reminders not syncing | Check iCloud sync in System Settings |
Silent permanent denial after upgrade (#154)
If one service (typically Calendar) returns access denied while the other works, and --print-tcc-path and System Settings report the permission as granted, you are likely hitting the #154 signature: a TCC row created by a pre-v1.7.1 (ad-hoc-signed) build is pinned to that old build's code hashes. The upgraded Developer ID binary can never match it, and on macOS 26.5+ the OS only allows the healing re-prompt when the binary carries the matching com.apple.security.personal-information.* entitlement.
As of v1.14.0+ the startup banner surfaces this directly — a [drift] TCC.db <service> entry pins a code requirement this binary no longer satisfies line (#155) — when the Security-framework check can confirm the csreq mismatch. Before that, every status-API diagnostic (including the banner) reported green, which is exactly what made this class silent. If you hit the denial through Claude Desktop's .mcpb install, the denial message itself now names the real blocker and the working paths instead of the dead-end --setup (#158).
Fix: upgrade to v1.11.0 or later (the binary now ships both entitlements), restart the host app (full Cmd+Q for Claude Desktop), and approve the permission dialog that appears on the first Calendar/Reminders access. Approving rewrites the TCC row keyed to the Developer ID requirement, so it survives all future upgrades. If you accidentally deny the dialog, re-enable the corresponding toggle in System Settings → Privacy & Security → Calendars or Reminders.
⚠️ Erratum for the #108-era workaround:
tccutil reset Calendar com.checheng.CheICalMCP(orReminders) does not work for a bare (non-bundled) binary — it fails withOSStatus error -10814(kLSApplicationNotFoundErr) because the binary has no LaunchServices registration.That covers both shipped builds: the Developer-ID
~/bin/CheICalMCPand the.mcpbserver/CheICalMCPare bare executables with an embedded Info.plist. The -10814 was seen with the #154-era builds; on 2026-11-04lsregister -dumphad no record forcom.checheng.CheICalMCPon a Mac with the Developer-ID~/bin/CheICalMCPinstalled (the command itself was not re-run there, because a lookup that unexpectedly succeeded would reset the real grant). Since #357 no message, banner line or--print-tcc-pathsection prints it.Instead: Switch CheICalMCP on, or off and on, in System Settings → Privacy & Security → Calendar / Reminders (#382).
Last resort, only on CheICalMCP v1.11.0 or later:
tccutil reset Calendar(ortccutil reset Reminders) without a bundle id clears that permission for every app on the Mac, and command-line tools or hosts recorded by version number may not ask again. The user decides and runs it; an agent must not. On a pre-entitlements binary (before v1.11.0) it leaves CheICalMCP permanently unable to re-prompt.
Claude Code updates rotate the host-side grant (#170)
Changed in v1.22.0, and again in v1.23.0 (#343): From v1.23.0 the rule depends on the grant macOS checks for this binary, that of its responsible process (which is not always the process that starts it), and it is the same for every host: before any dispatch the binary reads that context's authorization status. If it already has full access to both Calendar and Reminders (Terminal, VS Code, a Claude Code version-number entry, any host), that grant is kept and nothing is re-exec'd. A host that a policy (a configuration profile or Screen Time) restricts for either service keeps its attribution too, because CheICalMCP's own grant is not what that policy covers (#367). Otherwise, including a host with only one of the two or one the user denied, the binary re-execs in place as its own responsible process (
posix_spawn+POSIX_SPAWN_SETEXEC+ disclaim, the call Claude Desktop'sdisclaimermakes; pid and stdio unchanged), so macOS checks theCheICalMCPgrant, the one--setupcreates. This covers the MCP server,--cli,--print-tcc-pathand--setup;--setupre-execs under any host, because it is the action that creates CheICalMCP's own grant. (v1.22.0 re-exec'd by default only when Claude Code was the responsible process, and under any host whenCHE_ICAL_MCP_FORCE_DISCLAIMwas set.) A rotated Claude Code path has no grant, so after an update the binary re-execs and uses CheICalMCP's own grant instead of going stale.Seeing a re-exec (#361): every re-exec writes one line to stderr first, with the host and, except for
--setup, which reads no status, its Calendar and Reminders status. That line is a server's own record.--print-tcc-pathreports only its own start, in the context it runs in: when it re-executed, it addsCHE_ICAL_MCP_DISCLAIMED says this process re-executed at start (...), with the reason, the two statuses and the kind of host, also when the re-exec had no effect. A parent process could set that variable with no re-exec at all, so the line is marked as not verified: only values this binary can write are accepted, and anything else reads as not known.Migration from v1.22.0: under a host other than Claude Code, a context without full access used to keep its own (missing or partial) grant and now uses CheICalMCP's; if that is not granted yet, run
~/bin/CheICalMCP --setuponce in the foreground and restart the client. Under Claude Code, v1.22.0 re-exec'd whatever the status: a version-number entry with full access to both services is now kept, and one without still uses CheICalMCP's grant. A host other than Claude Code with full access is unaffected.Trade-off: once CheICalMCP's own grant is allowed, any process that runs this binary from a context without full access of its own (another app, a script, a launchd or cron job, a Claude Code session,
--cliincluded, and a host the user denied Calendar or Reminders to; not a host a policy restricts, which keeps its attribution, #367) reads Calendar and Reminders under that grant without a separate consent. v1.22.0 did this by default only under Claude Code, and for any caller that setCHE_ICAL_MCP_FORCE_DISCLAIM, without a notice.CHE_ICAL_MCP_NO_DISCLAIM=1keeps host attribution for the environment that sets it; it does not stop another process from running the binary without it.CHE_ICAL_MCP_FORCE_DISCLAIM=1re-execs even under a host with a grant of its own, but not under a host a policy restricts (the opt-out wins over it; a re-exec prints one stderr line (#361)).Not verified: CI does not cover reading the calendar through the re-exec. On 2026-10-28 v1.22.0 read the calendar through a forced re-exec with
--cli; a re-exec that a Claude Code worker launched throughClaudeCode.apptriggers by itself, and server mode after the re-exec, have not been observed yet.The wording of
--print-tcc-path, the banner, the access-denied message and--setup's WARNING line recognises Claude Code's native install and the build inside Claude Desktop, both under your home directory, with or without its symlinks resolved; another install (Homebrew, npm) is named by its app or executable, and the re-exec applies all the same (#356).The rest of this section applies to v1.21.0 and earlier, to a host that has full access of its own (its grant is kept), and with
CHE_ICAL_MCP_NO_DISCLAIM=1.
Under a Claude Code native install, the real executable lives at a versioned path (~/.local/share/claude/versions/<version>; ~/.local/bin/claude is just a symlink), and macOS TCC keys the host-side Calendar/Reminders grant to that path. Every Claude Code auto-update rotates the path and silently invalidates the grant — the classic symptom is "worked yesterday, broken right after an update", with System Settings accumulating stale bare-version-number entries (2.1.202, 2.1.203, …).
Fix: trigger any calendar tool call from Claude Code so macOS re-prompts (or re-creates the entry), then toggle the newest version-number entry ON in System Settings → Privacy & Security → Calendars / Reminders. Full checklist: the troubleshoot-tcc skill (/che-ical-mcp:check-tcc). Root cause is upstream (tracked in #170 — Claude Code would need a stable TCC identity); this repo can only detect and document it.
SSH Access
Since v1.23.0 (#343): an SSH session's context usually has no grant of its own, so by the rule above the binary re-execs onto CheICalMCP's own grant, as under any other host. Whether macOS honours that grant over SSH has not been measured (#382), so the steps below and the SSH error messages have not been changed for it: they were written before the re-exec, Workaround A is the first thing to try, and Workaround B may not be needed, or may not help, after a re-exec.
macOS TCC (Transparency, Consent, and Control) grants privacy permissions per-application. SSH sessions run under sshd, which is a different security context — so permissions granted to Terminal or Claude Code locally do not carry over to SSH.
Workaround A — Run locally first (recommended):
Run
CheICalMCPonce on the target Mac locally (not over SSH)Grant Calendar and Reminders access when the TCC dialog appears
SSH sessions should then inherit the grant for the
CheICalMCPbinary
Workaround B — Grant Full Disk Access to sshd:
Open System Settings → Privacy & Security → Full Disk Access
Click +, press ⌘⇧G, type
/usr/sbin/sshd, and add itRestart the SSH session
⚠️ Workaround B grants
sshdbroad file access — only use this on machines you fully control.
launchd / Automation
When running CheICalMCP from launchd, cron, or other non-interactive automation, macOS TCC cannot show permission dialogs. Use --setup to pre-grant permissions:
# Step 1: Run once from Terminal (triggers TCC permission dialog)
CheICalMCP --setup
# Step 2: Grant Calendar & Reminders access in the dialog that appears
# Step 3: The binary now has permission — launchd jobs can use itDetection: CheICalMCP treats a session as non-interactive, where a permission dialog cannot appear, when the process is a direct launchd child, when there is no window-server (GUI login) session, or, for the MCP server only, when the
CIvariable is set; it then gives targeted error messages with--setupinstructions.TERMis not used (#165): a server that a GUI app such as Claude Desktop starts has noTERMbut is in the GUI session, so it is interactive, and an SSH shell withTERMbut no GUI session is not. For an indirect launch chain (launchd → Claude Code → CheICalMCP) the parent is not launchd, so the GUI session decides: under a LaunchDaemon, or with no GUI login, it is non-interactive; under a per-user LaunchAgent in the logged-in GUI session it counts as interactive and the access request is made. That reading follows from the rule; it has not been checked on a device.
--setupin non-interactive sessions (#143): if you run--setupitself from a non-interactive session (a direct launchd child, or no GUI session; aCIvariable does not count for--setup, #143) and permission is still undetermined,--setupnow skips the request and exits non-zero instead of hanging — a TCC dialog can't appear there, so it prints manual-grant instructions rather than blocking. Run--setupfrom a real Terminal to trigger the dialog. (An already-granted binary still reports success even when re-run non-interactively.)Note: If
--setupgrants permission but the MCP still fails under launchd, TCC may have associated the permission with the parent process. In that case, switch CheICalMCP on in System Settings → Privacy & Security → Calendar/Reminders; if it is not listed, run~/bin/CheICalMCP --setup(or the.mcpbbinary's path) from Terminal — the access request adds it.
Technical Details
Current Version: v1.23.0
Framework: MCP Swift SDK v0.12.0
Calendar API: EventKit (native macOS framework)
Transport: stdio
Platform: macOS 14.0+ (Sonoma and later — bumped from 13.0 in the post-1.10 cluster per #119)
Tools: 29 tools for calendars, events, reminders, tags, undo/redo, cleanup, and advanced operations
Version History
Version | Changes |
v1.23.0 | Self-disclaim follows the host's grant, one rule for every host (#343, #367): a host with full Calendar and Reminders access keeps its grant, and so does a host a policy restricts; any other host, Claude Code included, re-execs onto CheICalMCP's own grant, which |
v1.22.0 | Under Claude Code, CheICalMCP is its own responsible process (#343): when the responsible process macOS reports is Claude Code, the binary re-execs in place with the disclaim attribute so the |
v1.21.0 | Complete long-range listings, series ids that explain themselves, conference module and per-folder routing (#285/#310/#325/#326/#335/#337/#338): an event listing over more than four years is no longer cut at about 1461 days; a series whose first occurrence was deleted can be moved, copied and deleted again and its occurrences updated, and a bare series id that cannot be used says why (an earlier edited occurrence, or every remaining occurrence edited on its own); undo of a whole-series delete restores its exceptions inside the scan window; |
v1.20.0 | Date-only reminders, undo that refuses to overwrite, start dates and alarms on reads (#231/#235/#236/#243/#247/#262/#267/#297/#299/#301/#311): a bare |
v1.19.0 | In-place event moves, reminder date sync, verified self-update (#223/#225/#226/#227): |
v1.18.0 | Undo/redo completeness for reminders, explicit |
v1.17.0 | Reminder recurrence on read, explicit completion results, identity-guarded undo, strict boolean |
v1.16.1 | Type-safety + on-device-verification fixes (#184/#190/#191): non-object |
v1.16.0 | Recurrence exclusions + undo completeness + archive-event skill (#182/#185/#180): |
v1.15.0 |
|
v1.14.2 | Docs/skill-layer release — two-layer TCC authorization model (#168): |
v1.14.1 | Metadata correction — tool-count consistency. |
v1.14.0 | Claude Desktop tool-injection drop fixed (#166): a literal |
v1.13.0 | SwiftUI SetupWindow (#164): interactive |
v1.12.0 | Foreground |
v1.11.1 |
|
v1.11.0 | TCC healing re-prompt unblocked (#154): |
v1.10.0 | TCC drift detector + startup banner (#122): single-shot stderr banner at MCP-server startup with version/path/PID + drift signals (TCC.db path mismatch per-service, stale processes); opt-out via |
v1.9.0 | TCC access gate refactor (#108 Phase 2, closes #109): removed the process-lifetime |
v1.8.1 | Docs: |
v1.8.0 | Wire-format consistency wave + response-shape parameters (#101 cluster — 5 issues closed in 3 days, all |
v1.7.2 | Hardening + features wave (30+ commits over v1.7.1, all |
v1.7.1 | Security hardening (#20 #26): input validation (length limits + URL scheme allowlist) at all event/reminder entry points, prompt-injection wrapper on MCP read responses, parse-boundary validation for |
v1.7.0 | Attendee & organizer info (#17): read-only |
v1.6.0 |
|
v1.5.0 | Per-event timezone (#12): |
v1.4.0 | LLM reliability: Fix default search range (±2yr instead of distantPast/Future), |
v1.3.1 | Docs fix: Clarified that tags are MCP-level (not native Reminders.app tags); Apple provides no public API for native tags |
v1.3.0 | Reminder tags (MCP-level): |
v1.2.0 | Idempotent writes: |
v1.1.0 | Recurrence + Location: recurring events/reminders (daily/weekly/monthly/yearly), structured locations with coordinates, location-based reminder triggers (geofence enter/leave), rich recurrence output |
v1.0.0 | DX improvements: flexible date parsing (4 formats), fuzzy calendar matching, |
v0.9.0 | 4 new tools (20→24): |
v0.8.2 | i18n week support: |
v0.8.1 | Fix: |
v0.8.0 | BREAKING: |
v0.7.0 | Tool annotations for Anthropic Connectors Directory, auto-refresh mechanism, improved batch tool descriptions |
v0.6.0 | Source disambiguation: |
v0.5.0 | Batch delete, duplicate detection, multi-keyword search, improved permission errors, PRIVACY.md |
v0.4.0 | Copy/move events: |
v0.3.0 | Advanced features: search, quick range, batch create, conflict check, timezone display |
v0.2.0 | Swift rewrite with full Reminders support |
v0.1.x | Python version (deprecated) |
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Release Process (for maintainers)
Version numbers live in three places with different semantics:
File | Role | When to bump |
| Source of truth; appears in | Every release |
| macOS bundle version | Every release; must match |
| Claude Desktop bundle manifest shipped inside | Every release; must match |
| MCP Registry submission snapshot | Only when re-submitting a new |
scripts/build-mcpb.sh enforces the first three match; it will fail the build if any drifts. server.json is intentionally decoupled because bumping it requires a rebuilt .mcpb, a fresh SHA256, and a re-submission — steps that don't happen every source release.
Signing & Notarization (required for macOS 26+)
Starting v1.7.1, release binaries are signed with a Developer ID Application certificate and notarized via Apple's notarytool. This is required on macOS 26 — ad-hoc signed binaries cannot trigger Calendar / Reminders TCC permission dialogs there.
Prerequisites (one-time setup):
Apple Developer Program enrollment.
Developer ID Application certificate installed in login keychain.
Verify with:
security find-identity -p codesigning -v(must showDeveloper ID Application: <Your Name> (<TeamID>)).Your Team ID is your own — find it at https://developer.apple.com/account → Membership Details. (The maintainer's
6W377FS7BSshown anywhere in this repo is for reference only.)
notarytoolkeychain profile (any name;che-ical-mcpis the default the build script looks for).Create interactively (recommended — keeps password out of shell history):
xcrun notarytool store-credentials che-ical-mcp --apple-id <your-apple-id> --team-id <your-team-id> # notarytool will prompt for the app-specific passwordApp-specific password: generate at https://account.apple.com → Sign-In and Security → App-Specific Passwords. Use a single-purpose password (e.g. named
che-ical-mcp); revoke + regenerate if leaked. Never pass it via--passwordon the command line — it lands in~/.zsh_history.
Export your identity for the build script:
export DEVELOPER_ID='Developer ID Application: <Your Name> (<TeamID>)' export NOTARY_PROFILE='che-ical-mcp' # match what you set up in step 3Persist these in
~/.zshrcor a project-local.envrc(gitignored). The script intentionally has no defaults for these, so a fresh fork doesn't fail with errors referring to the maintainer's identity.
Per-release flow:
make release-signed # builds universal binary → signs + notarizes → packages .mcpb
gh release create vX.Y.Z mcpb/server/CheICalMCP mcpb/server/CheICalMCP.sha256 mcpb/che-ical-mcp-X.Y.Z.mcpb mcpb/che-ical-mcp-X.Y.Z.mcpb.sha256 --notes "..."make release-signed runs scripts/build-mcpb.sh, which after creating the universal binary calls scripts/sign-and-notarize.sh. The signing script does pre-flight checks (cert + notarytool profile) and fails fast with friendly messages if anything's missing. Notarization typically takes 1–15 minutes (notarytool submit --wait blocks until Apple finishes).
Verification after build (run all three to confirm end-to-end):
# 1. Signature properties (cert + hardened runtime + team ID)
codesign -dv --verbose=2 mcpb/server/CheICalMCP
# Expected:
# Authority=Developer ID Application: <Your Name> (<TeamID>)
# TeamIdentifier=<TeamID>
# flags=0x10000(runtime)
# Signature size in the few thousand bytes range (varies by cert chain)
# 2. Signature integrity
codesign --verify --deep --strict --verbose=2 mcpb/server/CheICalMCP
# Expected: exit 0, no warnings
# 3. Notarization end-to-end (this is the real "Gatekeeper would accept" gate)
spctl -a -vvv -t install mcpb/server/CheICalMCP
# Expected: <binary>: accepted; source=Notarized Developer ID
#
# Note on flag choice (verified empirically on macOS 26.4.1, 2026-05-04):
# -t execute → rejected "code is valid but does not seem to be an app"
# (Apple's "execute" type expects a .app bundle structure,
# not raw Mach-O CLI binaries)
# -t install → accepted; source=Notarized Developer ID ← use this
# -t open → rejected "Insufficient Context"
#
# Apple's Code Signing Guide describes -t execute as the assessment type for
# "applications and tools", but on macOS 26 raw Mach-O binaries fall through
# the .app bundle check. -t install is the documented assessment type for
# software being installed (which describes how a CLI binary lands in ~/bin),
# and is the type that returns the actual notarization verdict in practice.
# Re-test if Apple changes this behavior in a future macOS update.Local dev iteration without signing latency:
SKIP_CODESIGN=1 ./scripts/build-mcpb.sh # ad-hoc signed; do NOT ship the result
make install # installs ad-hoc to ~/bin (dev only)The build-mcpb.sh script also auto-skips signing when DEVELOPER_ID is unset OR the cert isn't in your keychain — so contributors / CI / forks can build a working unsigned .mcpb for testing without manually setting SKIP_CODESIGN. (You'll see a clear "Skipping codesign" warning when this happens.)
Signing identity environment:
Env var | Default | Required for |
| (unset — auto-skip signing) | Signed release |
| (unset — fail-fast in | Signed release |
|
| Custom entitlements file |
| (unset) | Force-skip signing even with cert present (set to |
| (unset) | Fail-fast if signing prerequisites missing (set to |
Known limitation — no stapling: stapler staple does not support raw Mach-O binaries (only .app / .pkg / .dmg bundles). After notarization, Gatekeeper will online-check the binary on first launch instead of reading a stapled ticket. End users behind air-gapped networks may see "cannot verify developer" warnings; one launch with network resolves it (Apple caches the verdict). Mitigation: xcrun stapler staple on a future .pkg wrapper if needed.
Troubleshooting:
Notarization rejected?
xcrun notarytool log <submission-id> --keychain-profile $NOTARY_PROFILEshows Apple's reason. The signing script prints the submission ID on every run.codesigncomplains about missing identity?security find-identity -p codesigning -vto confirm cert is present + valid;xcrun notarytool history --keychain-profile $NOTARY_PROFILEto confirm the profile works.Cert expired? Re-issue at https://developer.apple.com/account/resources/certificates, install, re-export
DEVELOPER_ID.Security warning: don't unlock signing keychain on shared / untrusted machines. The cert + private key signing artifact is supply-chain critical.
License
MIT License - see LICENSE for details.
Author
Created by Che Cheng (@kiki830621)
If you find this useful, please consider giving it a star!
Recurrence read names (#198, unreleased): use event_recurrence_rules for events and reminder_recurrence_rules for reminders. Both are rule arrays, but missing selectors and end-date rendering differ. The legacy recurrence_rules alias is retained. Clients rejecting unknown fields need decoder updates. See the format comparison.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP connector for Apple Reminders — search, create, complete, and edit via your own Mac.
EU-hosted calendar MCP: Google, Outlook, iCloud, GMX, WEB.DE, CalDAV. Read, book, RSVP, sync.
Hosted email MCP for your own Gmail, Outlook.com, Microsoft 365, iCloud or IMAP inbox: read, search, draft, reply in thread, forward and file mail. It moves or flags up to 500 messages in one call, and a send leaves exactly one copy in Sent. A calendar is a separate connection, and connecting one adds diary and scheduling tools.
Mail, Calendar, Messages, Notes, Contacts, Slack on your Mac for AI. Remote needs Pro or free trial.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceA macOS-only MCP server that exposes Apple Reminders to AI clients, enabling create, read, update, delete, search, and organization of reminders and lists through natural language.3MIT
- AlicenseNot gradedqualityDmaintenanceMCP server for Apple Calendar via native EventKit API with proper recurring event support.26 npm10MIT
- AlicenseNot gradedqualityDmaintenanceMCP server for Apple Calendar, Mail, Reminders, and Files on macOS using native frameworks.24 npm22MIT
- AlicenseNot gradedqualityDmaintenanceA local-first MCP server for macOS that enables AI assistants to read and manage Apple Calendar events via EventKit, with configurable permissions, audit logging, and a native setup app.MIT