Skip to main content
Glama
mathewcsims

mail-calendar-mcp

by mathewcsims
README.md
# mail-calendar-mcp

A local MCP server giving full read/write control over Apple **Mail.app** and **Calendar.app** on macOS — every account configured in either app (iCloud, Google, Exchange, IMAP, etc.) is exposed uniformly, with no per-provider setup and no GUI/computer-use automation. All automation is done via JXA (`osascript -l JavaScript`) talking to the same AppleScript dictionaries Mail.app/Calendar.app themselves expose.

## Status

Feature-complete: all of Mail and Calendar's core scriptable surface is implemented, plus computed availability. Every tool below was exercised through the real MCP protocol against a live Mail.app/Calendar.app during development, not just type-checked — several real bugs were found and fixed or explicitly documented as platform limitations in the process (see below).

**Calendar runs on a native EventKit backend by default** (`helper/main.swift`, compiled to `bin/calendar-helper` by `npm run build`). This exists because Calendar.app's AppleScript bridge silently no-ops on several operations. The native backend fixes the confirmed recurring-event-delete and calendar-delete bugs, and adds capabilities AppleScript cannot express at all: per-occurrence vs whole-series edits/deletes (`occurrenceDate` + `span`), stable `calendarId`s, calendar source selection, richer event detail (attendees/alarms/detached-occurrence status), and cross-calendar queries in one call. If the helper binary is missing or lacks Calendar permission, calendar tools fall back to the JXA path automatically (safe even for writes — the permission check runs before any operation). `MCM_CALENDAR_BACKEND=jxa` forces the old path. Mail remains JXA-only: there is no public native API for Mail.app automation.

## Tool inventory

### Mail

| Tool | Notes |
|---|---|
| `mail_list_accounts`, `mail_get_account`, `mail_get_account_stats` | Read-only |
| `mail_list_mailboxes`, `mail_create_mailbox` | Works reliably |
| `mail_rename_mailbox` | **Confirmed broken** — Mail rejects the property assignment. Rename manually in Mail.app. |
| `mail_delete_mailbox` | **Confirmed broken on cloud-synced accounts** (iCloud tested) — untested on a local "On My Mac" mailbox. `[IRREVERSIBLE]`, requires `confirm:true`. |
| `mail_search_messages`, `mail_get_message`, `mail_get_message_source`, `mail_get_message_headers`, `mail_list_attachments`, `mail_get_attachment` | Read-only, paginated (default 50/max 500) |
| `mail_set_read_status`, `mail_set_flagged`, `mail_set_flag_color`, `mail_move_message`, `mail_copy_message` | Works reliably |
| `mail_delete_message` | Moves to Trash, recoverable |
| `mail_permanently_delete_message` | `[IRREVERSIBLE]`, requires `confirm:true` |
| `mail_create_draft`, `mail_create_reply_draft`, `mail_create_forward_draft`, `mail_update_draft`, `mail_list_drafts`, `mail_delete_draft` | Requires `fromAddress` on new-compose calls — see gotcha below |
| `mail_add_attachment_to_draft` | **Unverified** — the JXA attachment-insertion syntax could not be live-tested; treat with suspicion |
| `mail_send_message` | `[IRREVERSIBLE]`, requires `confirm:true`. Two modes: send an existing `composeSessionId`, or compose+send fresh |
| `mail_list_signatures`, `mail_create_signature`, `mail_update_signature`, `mail_delete_signature` | Works reliably |
| `mail_list_rules` | Read-only by design — see permanent limitations below |

### Calendar

| Tool | Notes |
|---|---|
| `calendar_list_calendars` | Read-only. Returns an `index` for disambiguating duplicate calendar names (confirmed real on this machine: two calendars named "Calendar", two named "Birthdays") |
| `calendar_create_calendar`, `calendar_rename_calendar` | Works reliably. Create accepts `sourceName` (EventKit backend) to pick the account |
| `calendar_list_sources` | Calendar accounts/sources, for `calendar_create_calendar` placement. EventKit backend only |
| `calendar_delete_calendar` | Reliable via the EventKit backend. (JXA fallback confirmed broken for this — expect `PLATFORM_LIMITATION` there.) `[IRREVERSIBLE]`, requires `confirm:true` |
| `calendar_list_events`, `calendar_search_events` | Read-only. `startDate`/`endDate` are **required**, capped at 366 days — confirmed an unbounded query hangs indefinitely |
| `calendar_get_event` | By `uid`; EventKit backend adds attendees, alarms, recurrence detail, and per-occurrence fetch via `occurrenceDate` |
| `calendar_create_event`, `calendar_update_event` | Reliable. EventKit backend adds `alarmMinutesBefore` and, on update, `occurrenceDate`/`span` for one-occurrence vs whole-series edits — semantics AppleScript can't express |
| `calendar_delete_event` | Reliable via the EventKit backend, including recurring events and per-occurrence deletes (`occurrenceDate`/`span`). (The JXA fallback silently no-ops on recurring events — confirmed, caught by verify-after-write.) `[IRREVERSIBLE]`, requires `confirm:true` |
| `calendar_add_alarm`, `calendar_list_alarms`, `calendar_remove_all_alarms` | Works reliably (display/sound/mail kinds) |
| `calendar_list_attendees`, `calendar_add_attendee` | Worked reliably in testing (better than pessimistic community reports this was designed against). Adding an attendee surfaces an extra `email:null` self/organizer entry (`isLikelyOrganizerSelf:true`) — not a bug |
| `calendar_remove_attendee` | **Unverified** — implemented but not live-tested |
| `calendar_get_availability` | Computed locally (not a native Calendar.app feature) from busy intervals across the calendars you specify |

### Cross-cutting

| Tool | Notes |
|---|---|
| `mail_app_status`, `calendar_app_status` | Health-check — distinguishes "app not running" from a genuine data error |

## Setup

```bash
npm install
npm run build   # or `npm run dev` for a live tsx run during development
npm test        # runs the vitest suite (Node-side logic only, see Testing below)
```

Add to your MCP client config (Claude Desktop / Claude Code) pointing at `dist/index.js` (after `npm run build`) or `src/index.ts` via `npx tsx` for development.

Building requires the Xcode Command Line Tools (`swiftc`) for the native calendar helper.

### One-time macOS permission grants

Two separate macOS permissions are involved (both one-time, both per host app — the app that spawns the server, e.g. Claude Desktop or your terminal):

1. **Automation** (Mail + Calendar via JXA): the first JXA call triggers "`<host>` wants to control `Mail`/`Calendar`" — click OK. Re-enable under **System Settings → Privacy & Security → Automation** if ever denied.
2. **Calendars — Full Access** (native EventKit backend): the host app needs Full Access under **System Settings → Privacy & Security → Calendars**. Note macOS may silently grant only "Add Events Only" without ever prompting — if `calendar_app_status` reports the helper unauthorized, flip that entry to Full Access manually. Until then, calendar tools transparently fall back to JXA.

If a tool call fails with error code `AUTOMATION_NOT_AUTHORIZED`, these are the settings to check. The ad-hoc code signature on `bin/calendar-helper` changes on every rebuild, which can re-trigger the Calendars grant.

## Important gotcha: `fromAddress`

Creating a draft or sending without specifying `fromAddress` silently uses Mail.app's global default "send new messages from" account — confirmed during development that this is **not necessarily the account you intended** (on the development machine it defaulted to an unrelated work account rather than the personal account being targeted). `fromAddress` is a **required** field on `mail_create_draft` and on `mail_send_message` when composing fresh (not required when sending an existing `composeSessionId`, which already has an account fixed).

## Design notes

- **Injection-safe**: every JXA invocation uses `execFile('osascript', ['-l','JavaScript', <fixed script path>, <JSON string>])` — never a shell string, never AppleScript source built from caller input. See `src/jxa/runner.ts`.
- **Timeouts**: every call has a per-operation timeout with `SIGKILL` on expiry, since Mail/Calendar can hang against an offline/asleep account or an unbounded query. Verified against both a deliberately-hung script and a real unbounded Calendar query during development.
- **Error taxonomy**: see `src/errors.ts` — tool failures return one of a fixed set of codes (`APP_NOT_RUNNING`, `AUTOMATION_NOT_AUTHORIZED`, `ACCOUNT_OFFLINE_OR_ASLEEP`, `NOT_FOUND`, `TIMEOUT`, `PLATFORM_LIMITATION`, `INVALID_INPUT`, `UNEXPECTED_OSA_ERROR`) rather than opaque strings. `PLATFORM_LIMITATION` specifically covers writes that report success but don't actually take effect — caught by re-reading state after every mutating call.
- **Destructive-tool gating**: the highest-risk tools (permanent delete, send, delete calendar/mailbox) require an explicit `confirm: true` argument in addition to whatever gating your MCP client applies, as defense-in-depth against an accidental or hallucinated call. See `requireConfirm()` in `src/tools/register.ts`.
- **Object identity**: Mail messages are addressed by `{accountName, mailboxPath, messageId}` (Mail's internal numeric id) or by RFC822 Message-ID; mailboxes by hierarchical name path; Calendar events by their iCalendar `uid` (stable via scripting, unlike calendars themselves, which have no queryable uid — hence the `calendarIndex` disambiguator).
- **Native Swift/EventKit helper**: originally deferred (most JXA calendar operations worked in initial testing), later built once the JXA bridge's failures proved painful in real use — see the Status section. `helper/main.swift` is a single-file CLI compiled by `npm run build:helper` with an embedded Info.plist (linker `-sectcreate`) and an ad-hoc code signature; it speaks the same JSON-envelope contract as the JXA scripts, so `src/helper/backend.ts` can route per-call with JXA fallback. EventKit's public API cannot write attendees, so attendee add/remove stays on JXA regardless of backend.

## Known permanent platform limitations

(Not bugs — confirmed not achievable via AppleScript/JXA, and in most cases no alternative API exists either.)

- **Mail**: creating/editing Mail rules (rules can only invoke a script as an *action*, not be created externally — `mail_list_rules` is read-only by design); mail account creation/credential management.
- **Calendar**: sending or RSVP-replying to meeting invitations; to-dos (moved entirely to Reminders.app, a separate app/dictionary, out of scope here).

## Known confirmed-broken/flaky operations

(Found via live testing, not guessed — reproduced deterministically before being documented.)

- `mail_rename_mailbox` — the property assignment is rejected outright.
- `mail_delete_mailbox` — fails on cloud-synced accounts (confirmed on iCloud); untested on local mailboxes.
- `mail_add_attachment_to_draft` — syntax unverified, no live test possible; treat with suspicion.
- `calendar_delete_calendar` — **fixed by the EventKit backend**; the JXA fallback remains broken.
- `calendar_delete_event` on a **recurring** event — **fixed by the EventKit backend**; the JXA fallback reports success without actually deleting (silently wrong unless caught, which is why the JXA path verifies its result before reporting success).
- `calendar_remove_attendee` — implemented but not live-tested; treat with suspicion until verified. (EventKit's public API cannot write attendees at all, so this stays on JXA regardless of backend.)

## Testing

There's no way to unit-test "did this actually change Mail.app/Calendar.app state" outside a live session, so verification is two-tiered:

1. **Manual checklists** (`test/manual-checklists/`) — the primary verification method, run against dedicated scratch fixtures so destructive-op testing never touches real mail/events:
   - Mail: a `Testing/MCP-Scratch` mailbox on a non-work account, all send-tests target only your own address.
   - Calendar: a local `MCP-Scratch` calendar ("On My Mac", to avoid sync churn).
   - One leftover artifact from development still needs manual cleanup: an empty `MCP-Delete-Test` mailbox in iCloud Mail (mailbox deletion via scripting is broken, see above). Two recurring test events stuck in the `MCP-Scratch` calendar — the JXA delete bug's own evidence — are removable via the EventKit backend.
2. **`npm test`** (vitest) — covers only the Node-side logic that doesn't need live app state: error-code mapping (`test/errors.test.ts`), destructive-tool confirm-gating (`test/register.test.ts`), JSON envelope parsing robustness (`test/jxa-runner.test.ts`), and free/busy gap computation (`test/availability.test.ts`).

TDQS

A3.7/5.0

Scored across 51 tools

Disambiguation5/5

Each tool has a clear domain prefix (calendar_ or mail_) and a distinct action on a specific resource. Overlaps are minimal and descriptions clarify any ambiguity.

Naming Consistency5/5

All tools follow a consistent snake_case verb_noun pattern with domain prefixes. The naming is uniform within and across both calendar and mail domains.

Tool Count4/5

51 tools is high but warranted for covering two full applications (Mail and Calendar). The granularity supports detailed operations, though some niche tools (e.g., mail_get_message_headers) could be merged.

Completeness5/5

CRUD operations, alarms, attendees, signatures, drafts, flags, attachments, and advanced features like availability and raw message source are all covered, with documented platform limitations.

Maintenance

ActivityStale
ResponsivenessNo issues