# che-ical-mcp [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![macOS](https://img.shields.io/badge/macOS-14.0%2B-blue)](https://www.apple.com/macos/) [![Swift](https://img.shields.io/badge/Swift-5.9-orange.svg)](https://swift.org/) [![MCP](https://img.shields.io/badge/MCP-Compatible-green.svg)](https://modelcontextprotocol.io/) **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. [English](README.md) | [繁體中文](README_zh-TW.md) --- ## 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: ```bash claude plugin marketplace add PsychQuant/che-ical-mcp claude plugin install che-ical-mcp@che-ical-mcp ``` **Claude Desktop** — download the latest `.mcpb` from [Releases](https://github.com/PsychQuant/che-ical-mcp/releases/latest) and double-click to install. **Standalone MCP** — the 29-tool server on its own, no plugin extras: ```bash 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/CheICalMCP ``` On 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](#installation) for the full guide. --- ## 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
Calendars (4) | Tool | Description | |------|-------------| | `list_calendars` | List all calendars and reminder lists (includes source_type) | | `create_calendar` | Create a new calendar | | `delete_calendar` | Delete a calendar | | `update_calendar` | Rename a calendar or change its color (v0.9.0) |
Events (4) | Tool | Description | |------|-------------| | `list_events` | List events with filter/sort/limit (v1.0.0) | | `create_event` | Create an event (with reminders, location, URL, per-event timezone, recurrence with excluded dates) | | `update_event` | Update an event (including timezone, recurrence, span for recurring) | | `delete_event` | Delete an event (with occurrence support for recurring) | > **Recurrence exclusions (#182)**: `create_event` (and each `create_events_batch` item) accepts `recurrence.excluded_occurrence_dates` to 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 one `undo` removes 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 is `skipped`, but extra exclusions already present on the existing series (beyond those requested) are not detected.
Reminders (8) | Tool | Description | |------|-------------| | `list_reminders` | List reminders with filter/sort/limit, tags extraction (v1.0.0) | | `create_reminder` | Create a reminder with due date, tags (v1.3.0); a bare `YYYY-MM-DD` due date makes it date-only (#267), and the response then carries `date_sync` with `aligned` judged on the saved reminder (#301) | | `update_reminder` | Update a reminder (including tags, `clear_due_date`) (v1.3.0) | | `complete_reminder` | Mark as completed/incomplete | | `delete_reminder` | Delete a reminder | | `search_reminders` | Search reminders by keyword(s) or tag (v1.3.0) | | `list_reminder_tags` | List all unique tags with usage counts (v1.3.0) | | `cleanup_completed_reminders` | Delete all completed reminders in one call, dry_run preview by default (v1.7.2); one `undo` recreates what it deleted (#243) | **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](docs/REMINDER_RECURRENCE.md). **Rescheduling reminders (#227/#235/#237):** Reminders.app shows the date of a reminder's earliest absolute-date alarm, 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): 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). **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).
Advanced Features (10) ✨ New in v0.3.0+ | Tool | Description | |------|-------------| | `search_events` | Search events by keyword(s) with AND/OR matching | | `list_events_quick` | Quick shortcuts: `today`, `tomorrow`, `this_week`, `next_7_days`, etc. | | `create_events_batch` | Create multiple events at once (with per-event timezone) | | `check_conflicts` | Check for overlapping events in a time range | | `copy_event` | Copy an event to another calendar; `delete_original` moves it like `move_events_batch` (recurring events refused) | | `move_events_batch` | Move events to another calendar in place, keeping recurrence and attendees; `span` / `occurrence_dates` for recurring events (#226) | | `delete_events_batch` | Delete events by IDs or date range, with dry-run preview (v1.0.0) | | `find_duplicate_events` | Find duplicate events across calendars (v0.5.0) | | `create_reminders_batch` | Create multiple reminders at once (v0.9.0) | | `delete_reminders_batch` | Delete multiple reminders at once (v0.9.0); one `undo` recreates them (#243) | **Moving events (#226):** a move reassigns the event's calendar, so recurrence, attendees and every other field are kept. The identifier stays the same within an account and changes across accounts (each result reports `id_changed` and `new_event_id`). 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/Redo (3) ✨ New in v1.4.0 | Tool | Description | |------|-------------| | `undo` | Undo the most recent calendar/reminder operation | | `redo` | Redo the last undone operation. Only completions are written again; for a create, delete, update or move, redo writes nothing, answers `success: false` with the tool to use, and drops that entry from the redo history (#247) | | `undo_history` | 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. **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 scan window (from one day before the first occurrence to the end of the rule, at most four years): 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. `undo_history` lists such a record as `Deleted event: (undo recreates the series and its exceptions within the checked window: …)` and a `delete_events_batch` record holding one as `Batch (<N> operations; undo recreates <K> series and their exceptions within the checked window: …)` (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 (a partial restore is #287). 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. </details> --- ## Installation The quick paths live at the [top of this README](#install). 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](https://github.com/PsychQuant/che-ical-mcp/releases), double-click, and restart Claude Desktop. **Manual config:** download the binary, then point `claude_desktop_config.json` at it. ```bash 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-mcp ``` Edit `~/Library/Application Support/Claude/claude_desktop_config.json`, then restart Claude Desktop: ```json { "mcpServers": { "che-ical-mcp": { "command": "/usr/local/bin/che-ical-mcp" } } } ``` ### Claude Code — plugin (recommended) ```bash claude plugin marketplace add PsychQuant/che-ical-mcp claude plugin install che-ical-mcp@che-ical-mcp ``` - Inside Claude Code, the slash equivalents `/plugin marketplace add PsychQuant/che-ical-mcp` and `/plugin install che-ical-mcp@che-ical-mcp` work the same way. - Add the marketplace through its **Git repo** (`owner/repo`), not a raw `marketplace.json` URL — the plugin `source` is a same-repo relative path (`./plugin`) that only resolves when added via Git. - Also bundled in the `psychquant-claude-plugins` aggregator (`claude plugin install che-ical-mcp@psychquant-claude-plugins`); both serve the same versioned binary. - The wrapper auto-downloads the binary to `~/bin/CheICalMCP` on first use if it isn't already there. ### Claude Code — standalone MCP ```bash 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) ```bash 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 build` directly — the upstream MCP SDK has a concurrency error ([swift-sdk#214](https://github.com/modelcontextprotocol/swift-sdk/issues/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 `NSCalendarsFullAccessUsageDescription` to VS Code's Info.plist: > > ```bash > # 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 appear > ``` > > **Note:** 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: ```bash ~/bin/CheICalMCP --self-update ``` This 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: ```bash # 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. --- ## v1.0.0 Features ### Flexible Date Parsing All date parameters now accept 4 formats: | Format | Example | Interpretation | |--------|---------|----------------| | Full ISO8601 | `"2026-02-06T14:00:00+08:00"` | Exact date and time (offset preserved) | | Without timezone | `"2026-02-06T14:00:00"` | Uses event `timezone` if provided, otherwise system timezone | | Date only | `"2026-02-06"` | Midnight in event `timezone` or system timezone; for a reminder `due_date`, a date-only reminder with no time (#267, see "Date-only reminders") | | Time only | `"14:00"` | 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) ``` - **`timezone`** parameter accepts IANA identifiers (e.g., `Europe/Berlin`, `America/New_York`, `Asia/Taipei`) - When `timezone` is provided, naive datetimes (without offset) are interpreted in that timezone - Event output includes the event's own timezone in `timezone` field and formats `start_date_local`/`end_date_local` accordingly - Available on `create_event`, `update_event`, and `create_events_batch` - Undo/redo preserves per-event timezone ### 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 | |-------|------|-------------| | `name` | string or null | Display name, null if not in Address Book | | `email` | string | Email address extracted from participant URL | | `role` | string | One of: `unknown`, `required`, `optional`, `chair`, `non_participant` | | `status` | string | One of: `unknown`, `pending`, `accepted`, `declined`, `tentative`, `delegated`, `completed`, `in_process` | | `type` | string | One of: `unknown`, `person`, `room`, `resource`, `group` | | `is_current_user` | boolean | Whether this participant is the current user | **`organizer`** (object, optional) — Present when the event has an organizer. Contains: | Field | Type | Description | |-------|------|-------------| | `name` | string or null | Display name | | `email` | string | Email address | | `is_current_user` | 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 message lists all available calendars. ### Enhanced list/delete Tools - **`list_events`**: `filter` (all/past/future/all_day), `sort` (asc/desc), `limit` - **`list_reminders`**: `filter` (all/incomplete/completed/overdue), `sort` (due_date/creation_date/priority/title), `limit` - **`delete_events_batch`**: date range mode (`before_date`/`after_date`) + `dry_run` preview > **Breaking Change**: `list_events` and `list_reminders` now 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 message will list all available sources. --- ## Troubleshooting | Problem | Solution | |---------|----------| | Server disconnected | Rebuild with `make release && make install` | | Permission denied | Grant Calendar/Reminders access in System Settings > Privacy & Security | | Permission dialog never appears | See [Grant Permissions](#grant-permissions) for macOS Sequoia workaround | | **Permission denied over SSH** | See [SSH Access](#ssh-access) below | | **Permission denied under launchd** | See [launchd / Automation](#launchd--automation) below | | **One service denied while every diagnostic reports green** | See [Silent permanent denial after upgrade](#silent-permanent-denial-after-upgrade-154) below | | **Calendar/Reminders break again after every Claude Code update** | See [Claude Code updates rotate the host-side grant](#claude-code-updates-rotate-the-host-side-grant-170) below | | 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](https://github.com/PsychQuant/che-ical-mcp/issues/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](https://github.com/PsychQuant/che-ical-mcp/issues/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](https://github.com/PsychQuant/che-ical-mcp/issues/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` does **not** work for a bare (non-bundled) binary — it fails with `OSStatus error -10814` because the binary has no LaunchServices registration. And do **not** run a bare `tccutil reset Calendar` (without a bundle ID): it wipes Calendar grants for *every* app on the machine and, on a pre-entitlements binary, leaves CheICalMCP permanently unable to re-prompt. ### Claude Code updates rotate the host-side grant (#170) 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](https://github.com/PsychQuant/che-ical-mcp/issues/170) — Claude Code would need a stable TCC identity); this repo can only detect and document it. ### SSH Access 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):** 1. Run `CheICalMCP` once on the target Mac **locally** (not over SSH) 2. Grant Calendar and Reminders access when the TCC dialog appears 3. SSH sessions should then inherit the grant for the `CheICalMCP` binary **Workaround B — Grant Full Disk Access to sshd:** 1. Open **System Settings → Privacy & Security → Full Disk Access** 2. Click **+**, press <kbd>⌘</kbd><kbd>⇧</kbd><kbd>G</kbd>, type `/usr/sbin/sshd`, and add it 3. Restart the SSH session > ⚠️ Workaround B grants `sshd` broad 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: ```bash # 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 it ``` > **Detection**: CheICalMCP automatically detects non-interactive sessions (missing `TERM` env var or direct launchd child) and provides targeted error messages with `--setup` instructions. This works even for indirect launch chains (launchd → Claude Code → CheICalMCP). > > **`--setup` in non-interactive sessions** (#143): if you run `--setup` itself from a non-interactive session (no `TERM` / direct launchd child) and permission is still undetermined, `--setup` now **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 `--setup` from a real Terminal to trigger the dialog. (An already-granted binary still reports success even when re-run non-interactively.) > > **Note**: If `--setup` grants permission but the MCP still fails under launchd, TCC may have associated the permission with the parent process. In that case, manually add CheICalMCP in **System Settings → Privacy & Security → Calendar/Reminders**. --- ## Technical Details - **Current Version**: v1.20.0 - **Framework**: [MCP Swift SDK](https://github.com/modelcontextprotocol/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](https://github.com/PsychQuant/che-ical-mcp/issues/119)) - **Tools**: 29 tools for calendars, events, reminders, tags, undo/redo, cleanup, and advanced operations --- ## Version History | Version | Changes | |---------|---------| | 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 `YYYY-MM-DD` `due_date` makes a date-only reminder and a date-only reminder is overdue only once its day has ended; `create_reminder` / `update_reminder` report `date_sync` (with `aligned`), `update_reminder` gains `realign_to_due`; `list_reminders` / `search_reminders` return `start` and `alarms`; reminder dates are read and written in the Gregorian calendar whatever the region setting; `undo` / `redo` refuse to overwrite a change made elsewhere after the operation, an `update_event` that touched a recurring event is never undone, and a created series whose occurrence was edited on its own (time included) is not deleted; undo keeps location / email / absolute alarms, recurrence, URL, availability and list; batch deletes record undo. **BREAKING (behavior)**: bare-date `due_date`, overdue rule, undo / redo refusals, `redo` of anything but a completion writes nothing. | | v1.19.0 | **In-place event moves, reminder date sync, verified self-update** (#223/#225/#226/#227): `move_events_batch` reassigns the calendar instead of copy + delete, keeping recurrence, attendees and every field (identifier changes only across accounts; `span` / `occurrence_dates` for recurring events; per-event `method` / `id_changed` / `not_carried_over`; copy + delete only as a guarded fallback); `update_reminder` moves the start date and absolute-date alarms with the due date (Reminders.app shows the alarm's date) and reports `date_sync`, `clear_due_date` clears them, undo restores them; `--self-update` installs only a Developer ID signed + notarized binary, shared skeleton from `che-mcp-kit-swift` 0.2.x. **BREAKING (behavior)**: a recurring event without `occurrence_date` is refused; `copy_event` refuses to move a recurring event; a non-string `event_ids` entry is rejected. | | v1.18.0 | **Undo/redo completeness for reminders, explicit `discard_id`, strict booleans everywhere** (#196/#197/#198/#199/#202/#203/#206/#207/#208/#209/#211/#212/#214/#215/#216): undo writes back the recorded `completion_date` and redo restores the saved instant instead of re-inferring state; repeated completion preserves the original instant; `undo(discard_id)` explicitly removes a blocked head record by its stable `undo_history` ID (not-found targets no longer jam the stack for deleted reminders or dead events); event moves record the source occurrence so undo restores into the original calendar; `event_recurrence_rules` / `reminder_recurrence_rules` distinguish the two formats (legacy aliases kept); every tool annotation has an explicit tested policy, with `complete_reminder`, update and optional-move tools now `destructiveHint: true`. **BREAKING**: every boolean tool argument is a strict JSON boolean (strings/numbers rejected with `<key> must be a boolean`). Internals: reminder list/search filter/sort/limit inside the actor pre-snapshot, `list_reminder_tags` on the snapshot seam, linear tag scan (no exponential backtracking), immutable Sendable reminder write results, completion/recurring-history methods in a dedicated actor extension. | | v1.17.0 | **Reminder recurrence on read, explicit completion results, identity-guarded undo, strict boolean `completed`** (#194/#204/#205): `list_reminders` / `search_reminders` expose `has_recurrence`, full `recurrence_rules` (incl. `frequency_raw_value`) and a `due` object; `complete_reminder` separates the write outcome (`operation`) from the saved object (`observed`) and reports the successor as `next_occurrence` — observed once, synchronously, after save (on-device iCloud: the same ID advances in place, the finished occurrence is filed under a new ID), message in the reminder's own wall clock. Undo/redo of a recurring completion is identity-guarded: once the ID no longer resolves to the recorded occurrence it refuses explicitly and **discards** the entry so older operations stay undoable; not-found stays transient (#191). **BREAKING**: `completed` must be a JSON boolean on the three reminder tools (strings/numbers rejected before any read or write; omit/`null` keep their meaning; `--cli` JSON `null` → omitted). Three 6-AI verify rounds on PR #195 plus rounds on #200 / #201; two on-device stdio probes. 582 tests. | | v1.16.1 | **Type-safety + on-device-verification fixes** (#184/#190/#191): non-object `recurrence` now rejected (was silently dropped); `all_day` + `timezone` pairing rejected (was silently stripping the all-day flag and defeating exclusions across the dateline); undo of a recurring-series deletion rebuilds rules from value snapshots (fixes EKCADErrorDomain 1010) and a failed undo/redo no longer consumes the entry. 529 tests. | | v1.16.0 | **Recurrence exclusions + undo completeness + archive-event skill** (#182/#185/#180): `excluded_occurrence_dates` on create_event/batch (two-pass create-then-remove, compensating delete, first occurrence not excludable); batch/series deletions now record undo entries (one `.batch` unit); undo of a recurring create now removes the whole series; new `archive-event` skill — source-to-event archiving with correction tracking and `.claude/.ical/` project config. 514 tests. | | v1.15.0 | **`--print-tcc-path` execution context + versioned-host banner signal.** The TCC diagnostic now prints its parent process chain (self → … → launchd) with a context-dependence warning — the authorization status follows the responsible-process context (#168), so knowing *which* host the query ran under is load-bearing (#169). Parent-chain diagnostics polish: visible truncation/cycle markers, empty-`comm` linkage, `ps -ww`, decode/exit failure reporting, NOTE precision (#173). New drift-detector signal: "versioned Claude Code host + ungranted EventKit" — explains the #170 update-rotation breakage proactively at startup, suppressing the contradictory `--setup` hint in that scenario (#175). All three verified by 6-AI cross-model ensembles; a CWE-150 terminal-escape gap found by verify was fixed pre-merge. Version drift from the v1.14.2 plugin release aligned across all five version sites (#172). 490 tests. | | v1.14.2 | **Docs/skill-layer release — two-layer TCC authorization model** (#168): `troubleshoot-tcc` skill, `/check-tcc`, `mcpb/README.md`, and `plugin/CLAUDE.md` now document the host-app (responsible-process) TCC layer, the bare version-number System Settings entries (`2.1.202` = Claude Code's versioned binary), and the toggle-and-observe verification procedure. Binary byte-identical to v1.14.1 at release time (plugin-shell only; source version sites aligned later in #172). | | v1.14.1 | **Metadata correction — tool-count consistency.** `server.json` `description` said "24 tools" and `PROMOTION.md` said "20 tools"; the server actually exposes **29 tools** (matching `mcpb/manifest.json` `long_description` and the `ManifestParityTests` tool-parity guard). Corrected the registry-facing `server.json`, `docs/COMPETITIVE_ANALYSIS.md`, and `PROMOTION.md` to 29. No code or tool-surface changes — functionally identical to v1.14.0; this release exists solely to publish corrected registry metadata (registry versions are immutable). | | v1.14.0 | **Claude Desktop tool-injection drop fixed** (#166): a literal `&` in `mcpb/manifest.json` `display_name` made Desktop 1.18286.0 silently drop the whole 29-tool server from every conversation (Claude Code unaffected); changed `&` → `and`, confirmed by single-variable intervention on the failing install + a `ManifestParityTests` regression guard. Also aligned `serverInfo.name` to the kebab manifest id (hygiene; empirically refuted as the cause). **#154 sister batch**: csreq-mismatch TCC drift signal (#155, `SecCodeCheckValidity` self-check for the silent-denial class), `.mcpb` denial message no longer dead-ends on `--setup` for the already-`.denied` signature (#158), macOS badge 13.0 → 14.0 (#157), swift-nio 2.96 → 2.101 (#159). 454 tests. | | v1.13.0 | **SwiftUI SetupWindow** (#164): interactive `--setup` presents a live-status window (per-entity Grant buttons + resolved binary path) inside the #163 foreground `NSApplication`. **Desktop Calendar-denied fix** (#165): `isNonInteractive` misfired on `TERM == nil` for GUI-app-spawned servers → fast-failed before `requestFullAccess`, so the first-grant dialog never appeared through Claude Desktop; now uses a `CGSession` GUI-session signal. 429 tests. | | v1.12.0 | **Foreground `--setup`** (#163): interactive `--setup` now runs inside a foreground `NSApplication` so EventKit's Calendar TCC modal actually presents (previously silently denied from a bare CLI async context). Denial messages + startup banner surface the resolved binary path + a copy-pasteable `"<path>" --setup` command for the buried `.mcpb` binary. | | v1.11.1 | **`create_event` time-range validation** (#160): symmetric with `update_event` — rejects inverted / zero-duration timed events via a shared `validateTimeRange` guard. 405 tests. | | v1.11.0 | **TCC healing re-prompt unblocked** (#154): `Entitlements.plist` ships `personal-information.calendars` + `.reminders` — long-lived pre-v1.7.1 installs could hit silent permanent Calendar denial on macOS 26.5 (TCC row pinned to old cdhashes, healing re-prompt policy-blocked because the binary shipped no entitlements, every diagnostic reporting green); signed-binary release gate verifies both keys. **Non-interactive EventKit hardening** (#131 / #143 / #144 + #146–#150). **BREAKING**: deploy floor raised to macOS 14.0 (#119). 401 tests. | | 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 `CHE_ICAL_MCP_NO_BANNER=1`. Pipe-deadlock fix in subprocess helpers; CWE-117 stderr-injection defense on all interpolated banner values. | | v1.9.0 | **TCC access gate refactor** (#108 Phase 2, closes #109): removed the process-lifetime `has*Access` cache anti-pattern; per-call `EKEventStore.authorizationStatus(for:)` via new `AuthorizationGate` + `AuthorizationStatusSource` seam (Apple TN3153 pattern) — state changes surface immediately instead of silent stale-grant fail. Adds `--print-tcc-path` diagnostic flag. | | v1.8.1 | **Docs**: `mcpb/README.md` post-install / upgrade TCC permission setup guide (#108 Phase 1). | | v1.8.0 | **Wire-format consistency wave + response-shape parameters** (#101 cluster — 5 issues closed in 3 days, all `Refs #N` IDD + 6-AI ensemble verify). **Event listing response-shape params** (#47 / #101): `detail_level` (`summary`/`standard`), `fields` allow-list, `display_timezone` (strict IANA), `limit` (cap 10000) — LLM verbosity tuning. **Envelope unification** (#102 / #107, **breaking wire-format**): `list_events.metadata.returned` + `list_reminders.metadata.returned` removed; all 5 list/search envelopes use top-level `<entity>_count` with pre-limit semantic; `search_reminders.result_count` → `reminder_count`; `search_reminders` gains `limit` parameter (mirror `search_events`). MCP clients reading `metadata.returned` or `result_count` must update. **Validator hardening** (#101 F1–F3): `requireOptionalInt` uses `Int(exactly:)` closing the `Int.max` DoS trap; `detail_level` / `display_timezone` validators distinguish absent vs. non-string (no silent coerce). **Runtime-anchored drift detection** (#103, strengthening #101 M3): `formatEventDict` ↔ `validEventFields` divergence test now via `EventFormattingSource` seam + `FakeFormattableEvent`. **CHANGELOG reclass** (#106): wire-format renames moved from `Fixed` to `Changed` (Keep a Changelog 1.1.0). **Release pipeline fix**: pre-pack defense check now derives Team ID from `DEVELOPER_ID` cert (was comparing SHA hash against human-readable `Authority=` string). | | v1.7.2 | **Hardening + features wave** (30+ commits over v1.7.1, all `Refs #N` IDD with 6-AI verify). **`--self-update`** (#49) + **SHA-256 binary verify** (#98): existing-install upgrade path with cryptographic guarantee against corrupted releases. **`make install-signed`** (#50): maintainer dev TCC flow on macOS 26 — fail-fast on missing Developer ID + force codesign verification. **CI test workflow** (#51): PR-time `swift build` + `swift test` on macos-latest. **Sanitizer hardening cluster**: `escapeForStderr` full C0+DEL coverage (#73), `sanitizeForInterpolation` for executeUndo/executeRedo title interpolation (#74), CLIRunner stderr delegated to `writeFailureLog` for trusted-branch carve-out (#80), `writeFailureLog` 1024-char DoS cap (#86), `CLIError.invalidJSON` author-controlled-only contract doc (#85), `FileHandle.standardError.write` thread-safety + macOS PIPE_BUF=512 documented (#70 / #94). **Distribution polish**: stale-codesign-cache install snippets get `rm -f` preamble (#90 zh-TW parity for #62). **Post-v1.7.1 polish** (#46 #57 #58 #60): redo error interpolation parity, `build-mcpb.sh` step renumbering, `Entitlements.plist` documentation, `Makefile release-signed:` cwd note. **`cleanup_completed_reminders` tool** (#21): single-call cleanup of all completed reminders, `dry_run=true` default. | | 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 `days_of_week` / `days_of_month` / `alarms_minutes_offsets` (throws instead of silent-dropping invalid values), `Info.plist` catch-up, 42 new regression tests. | | v1.7.0 | **Attendee & organizer info** (#17): read-only `attendees` array and `organizer` object in event responses. Refactored shared `formatEventDict` method. | | v1.6.0 | **`--setup` flag** (#13): pre-authorize TCC permissions for launchd/automation. Non-interactive session detection (TERM + ppid). Combined SSH+launchd error messages. **`--cli` mode** (#14): invoke all 28 tools directly from command line without MCP server. Flag-based (`--key value`) and JSON stdin modes. Smart type inference for bool/int/double/array params. MCP Swift SDK 0.12.0 (Swift 6.3 compat). | | v1.5.0 | **Per-event timezone** (#12): `timezone` parameter on `create_event`/`update_event`/`create_events_batch`, event output uses event's own timezone, naive datetimes parsed in event timezone. **Clear due date** (#9): `clear_due_date` on `update_reminder`. **Weekday validation** (#5): `create_event`/`update_event` validate `start_time` weekday against `days_of_week`. **Undo/redo** (#8): 3 new tools (`undo`, `redo`, `undo_history`). **Recurring event fixes** (#7): occurrence-level delete/update with `occurrence_date`. **Swift 6 build** (#11): README updated for `make release` workflow | | v1.4.0 | **LLM reliability**: Fix default search range (±2yr instead of distantPast/Future), `searched_range` metadata in `search_events` response, `similar_events` hints in `create_events_batch`, LLM tips in tool descriptions | | 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): `#hashtag` text stored in notes for `create_reminder`/`update_reminder`/`create_reminders_batch`, tag-based filtering in `search_reminders`, new `list_reminder_tags` tool; MCP SDK 0.11.0. Note: tags are searchable via MCP but do not appear as native Reminders.app tags (Apple provides no public API for this) | | v1.2.0 | **Idempotent writes**: `create_event`, `create_events_batch`, `create_reminder`, `create_reminders_batch`, `create_calendar` now check-before-write to prevent duplicates on retry; responses include `skipped` count | | 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, `list_events`/`list_reminders` filter/sort/limit, `delete_events_batch` dry-run + date range mode | | v0.9.0 | **4 new tools** (20→24): `update_calendar`, `search_reminders`, `create_reminders_batch`, `delete_reminders_batch` | | v0.8.2 | **i18n week support**: `week_starts_on` parameter for `list_events_quick` (monday/sunday/saturday/system) | | v0.8.1 | **Fix**: `update_event` time validation bug, duration preservation when moving events | | v0.8.0 | **BREAKING**: `calendar_name` now required for create operations (no more implicit defaults) | | v0.7.0 | **Tool annotations** for Anthropic Connectors Directory, auto-refresh mechanism, improved batch tool descriptions | | v0.6.0 | **Source disambiguation**: `calendar_source` parameter for same-name calendars | | v0.5.0 | Batch delete, duplicate detection, multi-keyword search, improved permission errors, PRIVACY.md | | v0.4.0 | Copy/move events: `copy_event`, `move_events_batch` | | 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 | |------|------|--------------| | `Sources/CheICalMCP/Version.swift` — `AppVersion.current` | Source of truth; appears in `--version`, `help`, and MCP `serverInfo.version` | Every release | | `Sources/CheICalMCP/Info.plist` — `CFBundleVersion` | macOS bundle version | Every release; must match `AppVersion.current` | | `mcpb/manifest.json` — `version` | Claude Desktop bundle manifest shipped inside `.mcpb` | Every release; must match `AppVersion.current` | | `server.json` — `version` + `packages[].identifier` + `fileSha256` | **MCP Registry submission snapshot** | Only when re-submitting a new `.mcpb` to the MCP Registry (independent cadence) | `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): 1. Apple Developer Program enrollment. 2. Developer ID Application certificate installed in login keychain. - Verify with: `security find-identity -p codesigning -v` (must show `Developer ID Application: <Your Name> (<TeamID>)`). - Your Team ID is your own — find it at <https://developer.apple.com/account> → Membership Details. (The maintainer's `6W377FS7BS` shown anywhere in this repo is for reference only.) 3. `notarytool` keychain profile (any name; `che-ical-mcp` is the default the build script looks for). - Create interactively (recommended — keeps password out of shell history): ```bash xcrun notarytool store-credentials che-ical-mcp --apple-id <your-apple-id> --team-id <your-team-id> # notarytool will prompt for the app-specific password ``` - App-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 `--password` on the command line — it lands in `~/.zsh_history`. 4. Export your identity for the build script: ```bash export DEVELOPER_ID='Developer ID Application: <Your Name> (<TeamID>)' export NOTARY_PROFILE='che-ical-mcp' # match what you set up in step 3 ``` Persist these in `~/.zshrc` or 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**: ```bash 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): ```bash # 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: ```bash 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 | |---------|---------|--------------| | `DEVELOPER_ID` | _(unset — auto-skip signing)_ | Signed release | | `NOTARY_PROFILE` | _(unset — fail-fast in `sign-and-notarize.sh`)_ | Signed release | | `ENTITLEMENTS` | `Sources/CheICalMCP/Entitlements.plist` | Custom entitlements file | | `SKIP_CODESIGN` | _(unset)_ | Force-skip signing even with cert present (set to `1` or `true`) | | `REQUIRE_CODESIGN` | _(unset)_ | Fail-fast if signing prerequisites missing (set to `1` by `make release-signed` — canonical release path must not silently produce unsigned artifacts; do not set when running `./scripts/build-mcpb.sh` directly for fork-friendly dev builds) | **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_PROFILE` shows Apple's reason. The signing script prints the submission ID on every run. - `codesign` complains about missing identity? `security find-identity -p codesigning -v` to confirm cert is present + valid; `xcrun notarytool history --keychain-profile $NOTARY_PROFILE` to 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](LICENSE) for details. --- ## Author Created by **Che Cheng** ([@kiki830621](https://github.com/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](docs/REMINDER_RECURRENCE.md#recurrence-read-formats-198).