One consolidated MCP server for native macOS apps — Calendar & Reminders read/write, Messages & Notes content search over the native stores, id-first Mail with draft-and-open replies, plus read-only context and a few actions across Contacts, Photos, Safari, and Shortcuts. Python + FastMCP 2.0, managed with uv.
Replaces the pile of Apple MCP servers a life-cockpit otherwise juggles with a single modular adapter layer you own. Every read returns pointers (id + one-line summary + open-in-app deeplink), never full bodies — so it structurally avoids the context-bloat bug of the archived flagship server; bodies are a separate, bounded, opt-in fetch.
macOS only. See DESIGN.md for the rationale and CHANGELOG.md for what's landed.
Requires macOS and Python ≥ 3.11.
From source (works today):
git clone https://github.com/elfensky/macos-apps-mcp && cd macos-apps-mcp
uv syncThen point your MCP client at the project's own venv python — deterministic, and it carries the locked PyObjC wheels:
{
"mcpServers": {
"macos-apps": {
"command": "/absolute/path/to/macos-apps-mcp/.venv/bin/python",
"args": ["-m", "macos_apps_mcp"]
}
}
}From PyPI (once published — see the CHANGELOG): uvx macos-apps-mcp runs the server with no clone,
and the MCP config becomes "command": "uvx", "args": ["macos-apps-mcp"] (same "macos-apps" key).
Grant access when macOS prompts — the first call to each app triggers its dialog:
- EventKit — Calendar + Reminders (read/write).
- Automation (per app) — Mail, Notes, Contacts, Photos, Safari, Messages actions/reads.
- Full Disk Access — required for Messages content (
chat.db) and the fast Notes path (NoteStore.sqlite). Notes degrades to Automation without it; Messages content raises a clear, typed error telling you to grant it. Run thedoctortool to see what's granted.
Reads return pointers; results are capped per adapter. Bodies/content are a separate bounded
fetch. Writes/actions are skipped entirely when MACOS_APPS_READ_ONLY is set (see below).
| Tool | Args | Notes |
|---|---|---|
events |
when = today | week | YYYY-MM-DD |
list events as pointers |
reminders |
due = today | overdue | this-week | a list name |
list reminders as pointers |
calendars / reminder_lists |
— | containers (id + name) to target writes |
create_event / update_event |
title, start, end (ISO), calendar, location, notes, all_day, recurrence |
update is a full replace by id |
delete_event |
id, span, dry_run |
dry_run previews without deleting |
create_reminder / update_reminder |
title, due, list_name, notes, priority (0–9), start, recurrence |
update is a full replace by id |
complete_reminder |
id | marks complete |
A write targets its container by name or Pointer.id; an ambiguous name raises rather than
guessing. Recurrence is an RFC 5545 RRULE (FREQ/INTERVAL/COUNT/UNTIL subset, e.g.
FREQ=WEEKLY;INTERVAL=2;COUNT=10); a recurring reminder needs a due date; unsupported parts
(BYDAY, …) are rejected, not ignored.
Nothing here ever sends — replies and drafts open a compose window for you to review.
| Tool | Args | Notes |
|---|---|---|
mail |
subject-OR-sender substring | inbox matches; id = stable RFC822 message-id, message:// deeplink |
mail_body |
id | one message's plaintext, bounded + truncation-marked |
mail_attachments |
mailbox (inbox/sent/drafts/trash/junk), optional query |
attachment name/size/downloaded per message; works on Drafts |
create_draft |
to, subject, body | opens a draft for review — never sends; returns a locator |
mail_reply |
message_id, reply_body, include_quote |
native threaded reply (sets In-Reply-To/References), quoted original, opens for review — never sends |
| Tool | Args | Notes |
|---|---|---|
messages_chats |
— | conversation list (id + name); no content, no FDA needed |
messages_search |
query, limit |
search message text (decodes attributedBody), newest first |
messages_with |
contact (phone/email), country, limit |
recent messages with one person; country = calling code or 2-letter region (locale default, never +1) |
message_body |
id | full text of one message by guid |
| Tool | Args | Notes |
|---|---|---|
notes |
title/snippet substring | matching notes (id + snippet); diacritic- & smart-punctuation-insensitive |
notes_all |
— | every live note (id + "Account / Folder" + snippet), Recently Deleted excluded |
note_bodies |
ids (≤ 50) | opt-in plaintext hydration → [{id, body}] |
delete_note |
id, expect_title, dry_run |
moves to Recently Deleted; dry_run previews |
| Tool | Args | Returns |
|---|---|---|
contacts |
name substring | cards (name, org, first phone + email) |
photos |
search string | media (filename); matches the Photos search field |
safari_tabs |
— | every open tab (url + title) |
shortcuts |
name substring (empty = all) | the user's Shortcuts; id = stable UUID, shortcuts:// deeplink |
ping / now / doctor |
— | health check / time / permission diagnostics |
| Tool | Args | Notes |
|---|---|---|
create_contact |
given_name, family_name, organization | |
run_shortcut |
name or id, optional input_text |
runs a Shortcut; returns a bounded output snippet |
safari_open |
url | opens in a new tab; bare host → https://; only http/https allowed |
Set MACOS_APPS_READ_ONLY=1 (or true / yes) to register reads only — every write and action
tool is skipped, a safe-deploy guard. (Reads may still open apps / read local stores.)
uv sync
uv run pytest # unit tests (mock at the adapter boundary)
uv run pytest -m integration # real macOS / EventKit / TCC — run manually, never in CI
uv run ruff check . # lint (config in pyproject.toml)
uv run ruff format . # format
uv run macos-apps-mcp # run the server (stdio)ruff (lint + format, line-length 88) and pytest gate CI — full workflow in CONTRIBUTING.md.
macos-apps-mcp builds on prior work — the Apple Mail MCP it draws from, the EventKit/Photos servers it references, the project that pioneered the unified-Apple-MCP pattern, and FastMCP / PyObjC / the MCP spec it depends on. See CREDITS.md.