• MCP server
  • macOS
  • MIT
  • v1.5.0

Seven macOS apps your assistant can actually reach.

pyapple-mcp is a Model Context Protocol server for macOS. It hands Claude (or any MCP client) your Messages, Notes, Contacts, Mail, Reminders, Calendar and Maps — and it is specific about what each one costs you in permissions, because that is the part you should decide before installing anything. One string says which of the four classes — read, write, draft, send — each tool keeps.

The seven tools, grouped by how they reach your Mac

calendar reminders contacts maps

Read and written through EventKit, Contacts and MapKit — one privacy toggle each, and no app is launched.

notes

Driven by Apple Events, because macOS ships no framework for Notes — the one tool that has no other door.

mail messages

Read from local databases — these need Full Disk Access. The honest catch.

Install
$ pip install pyapple-mcp
$ pyapple-mcp-setup   # writes the Claude Desktop config for you

Access model

Three routes in, three different costs.

Most of what matters about a tool like this is not the feature list — it is which door each tool goes through. There are three, they are not equally expensive, and grouping the tools by door is the fastest way to see what you are agreeing to. Grant only the rows you want. The first row used to be smaller and the second used to be larger; four tools moved when their writes stopped needing an app.

Framework

EventKit, Contacts & MapKit

Asks for Calendars, Reminders and Contacts in Privacy & Security; maps asks for nothing at all. No Full Disk Access, and — since the writes moved here too — contacts and maps need no Automation grant whatsoever, while calendar and reminders need one only for open.

  • calendar

    Search and list events, find the gaps between them, ask what range each calendar actually holds, create one — repeating, with alarms, with invitations — reschedule it, move it, delete it, and list your calendars.

    Reads and writes both go through EventKit now, which is not only cheaper but more correct: over Apple Events, deleting the fourth occurrence of a five-week series deleted the first, and answering “no such event” took 60.06s where EventKit takes 0.006s. Only open, and the one argument that mails an invitation, still send an Apple Event.

  • reminders

    List and search reminders, name the lists that exist, create one, change it, complete it, un-complete it, delete it, open it.

    A full reminders list comes back in about 0.01s — the reason this route exists. complete and delete did not exist until recently, which is half of what anyone asks of a reminders app, and create silently dropped the due date it was given.

  • contacts

    Find a person by name, go the other way from a number or an address, add a card, add a number to a card that exists, correct the name on one, delete one.

    Nothing in this tool dispatches an Apple Event any more — reads and writes are both CNContactStore, and a check asserts the module has no handle on the script runner to reach through. 163 names in 0.128–0.137s warm against 7.87–8.94s for the corrected AppleScript, and a reverse lookup in 0.005–0.008s that Apple Events cannot perform at all.

  • maps

    Search places and get directions — distance, arrival time, and turn-by-turn.

    Through MapKit, with no window and no permission of any kind: 2.68s to 0.72s on a search, and it now returns real places instead of the sentence “please search manually”. It is also the one thing in this package that reaches the network. Favourites and Guides are not possible at all.

Apple Events

Automation

macOS shows the “wants to control” dialog once per app. You can revoke it later under Privacy & Security → Automation. This row has one tool in it, and it used to have more.

  • notes

    List, search and view notes; create one; append a line; rename it; replace text inside it; replace its whole body; delete it. List the folders, create one, rename one.

    The exception that cannot be fixed. macOS exposes no framework for Notes, so AppleScript is the only route in — and reading through plaintext rather than body is what makes it usable at all: on a 59-note store the HTML runs to 13,790,681 characters where the text is 14,463. The editing operations are mostly refusals, for a measured reason, and create is one of them now: it refuses a folder name that does not exist rather than making a folder out of a typo, which is why folders and folderCreate arrived alongside it.

  • Automation is a per-operation cost now, not a per-tool one — for every tool but this one. notes needs it for everything including its reads, because no framework exists. Elsewhere it is a short list, and the list is written out rather than summarised: the sentence “nothing launches an app except open was on this page and it was not true.

Local database

SQLite, read-only

Needs Full Disk Access for the MCP client. This is the expensive one — skip these two if you would rather not grant it.

  • mail

    Read unread mail; search headers, or search what the messages actually say; read one message whole; ask what each account can even answer about; send; reply in the thread; draft either without sending; show one message; mark one read; list mailboxes and accounts.

    Search runs against Mail's own local index rather than the network, which is why it returns at once. A thread is Mail's own conversation column, not a subject-line guess: on a 45,628-message index, guessing would have merged 2,516 subject lines and split 354 conversations. Four of its operations were answering about the wrong mailbox until recently. Composing, replying and marking read are Apple Events, split into three permission classes.

  • messages

    List your conversations, read one by its chat id, search the text of your messages, open one on screen, send an iMessage, and check what is unread.

    Reading is by conversation rather than by phone number. A group thread that returned 1 message of its 486 now returns the thread, and the busiest one-to-one went from 13,078 rows — 1,253 of them another chat's — to 11,841 with none foreign and none missing. Searching message text is new, and it reads both columns: text alone found 171 messages where both found 402. Sending is an Apple Event.

7
Tools, each one a single call with an operation argument
65
Operations across the seven, from a closed enum. *=read leaves 30
4
Classes — read, write, draft, send — granted per tool, not per install
5
Exit statuses from the command line, so a failed read never reads as an empty one
983
Automated checks in the repository, each one made to fail before it was believed

Install

Two commands, or one file you edit yourself.

The setup helper finds your Claude Desktop config and adds the server to it. If you would rather see exactly what changes, the manual route is four lines of JSON.

Setup helper

Recommended. Locates the config file and writes the entry.

Terminal
$ pip install pyapple-mcp
$ pyapple-mcp-setup

Restart Claude Desktop afterwards so it picks up the new server.

By hand

Add the server to your Claude Desktop config file:

~/Library/Application Support/Claude/claude_desktop_config.json

claude_desktop_config.json
{
  "mcpServers": {
    "pyapple": {
      "command": "pyapple-mcp"
    }
  }
}

What it needs

macOS
10.15 Catalina or later
Python
3.10 or later
Client
Claude Desktop, or any MCP client that can run a local command
Built on
FastMCP, with PyObjC for the macOS frameworks

Granting permission

Nothing is requested up front. macOS asks the first time a tool actually needs it, and every answer is reversible in System Settings → Privacy & Security.

Calendars
for calendar, reading and writing
Reminders
for reminders, reading and writing
Contacts
for contacts, reading and writing
Automation
for notes; for anything mail composes or marks; for every open; and for calendar's invitees. Nothing else
Full Disk
for reading mail and messages
Nothing
for maps — MapKit needs no grant, and there is no current location to ask for

Permissions

Per tool, per class — and the line is not read against write.

PYAPPLE_READ_ONLY was one switch for seven tools, so “read my mail, but manage my calendar” could not be said. It can now: every operation carries a class, and classes are granted per tool. The split that matters is not reading against writing — it is “changes my Mac” against “leaves my Mac and reaches another person”. Deleting a note is private and undoable. Sending an email is neither.

read
Changes nothing.
mail search · messages conversations · calendar list
write
Changes this Mac, privately — and the app that owns it can undo it.
notes delete · calendar reschedule · reminders complete · mail markRead
draft
Composes without transmitting. It puts the message in front of a person who can read it before it goes.
mail draft · mail replyDraft
send
Leaves the machine, reaches a person, and is not retracted by any amount of care afterwards.
mail send · mail reply · messages send · messages schedule

In ascending order of what it costs to be wrong about, which is the order the code declares them in. messages has no draft class at all: iMessage exposes no unsent message to AppleScript, so messages=draft grants nothing — which is the correct amount, and better than an operation that would read as a draft in every permission string on every machine while actually sending.

One string, wherever you keep it

The same spelling works in an MCP client's env block and in a shell: tool=class[+class], comma separated, with * for the tools you did not name.

A policy narrows only the tools it names. Without a *, every tool you did not mention keeps every operation — so PYAPPLE_PERMS="mail=read" is a read-only mail tool and six untouched ones. That is deliberate: saying something about mail is not saying anything about your calendar, and a parser that disarmed the calendar anyway would be removing a capability you never asked it to remove. It is also the most misread line on this page, reported twice as a leak. *=read narrows the rest; *=none removes it. pyapple perms names the tools a policy left alone rather than leaving you to infer them.

claude_desktop_config.json
{
  "mcpServers": {
    "pyapple": {
      "command": "pyapple-mcp",
      "env": {
        "PYAPPLE_PERMS": "mail=read+draft,calendar=all,messages=read,*=read"
      }
    }
  }
}

That policy leaves 37 of the 65 operations. The assistant can read and compose mail — including answering it — but has no way to post any of it, has the calendar in full, can read Messages and nothing else, and keeps only the reads everywhere it was not named. PYAPPLE_READ_ONLY=1 is still here, as the documented shorthand for *=read.

It can only take away

--perms narrows a single invocation of the command-line tool that ships in the same package, and it intersects rather than replaces: nothing you type at a shell can restore what an MCP configuration took away. If it could, the variable would be a suggestion.

A policy string that cannot be parsed stops the program, naming the token it choked on. There is no “could not understand, carrying on” path, because a policy that fails open sends the email.

It stops it as bad input — exit 2, the same status as a bad command line, because the operation never started — and the message says which of the two inputs was wrong rather than leaving you to guess between a variable somebody set months ago and the flag you just typed: usage: PYAPPLE_PERMS: 'mail@read' is not 'tool=class[+class…]'. That holds on every entry point, including pyapple perms itself, which is the command you would run to find out. The MCP server refuses to start for the same reason and says so on stderr, where a client's log picks it up: a server that is simply missing is worth looking for in its env block.

Both settings apply through the same intersection, so PYAPPLE_READ_ONLY=1 beside PYAPPLE_PERMS="mail=send" yields no mail send, whichever order they are read in.

Ask it what it is allowed to do

The first question after a refusal is why, and the answer used to be an archaeology exercise across a variable somebody set months ago and a flag on the command line. One command prints the effective policy and where each part of it came from.

Terminal
$ pyapple perms
Effective policy

contacts   read              search, lookup
notes      read              search, list, view, folders
messages   read              conversations, read, search, unread
mail       read+draft        unread, search, searchBodies, readMessage, coverage, draft, replyDraft, mailboxes, accounts
reminders  read              list, search, lists, open
calendar   all               search, list, calendars, free, coverage, create, reschedule, alarms, move, delete, open
maps       read              search, directions, listGuides

From:
  defaults          all seven tools, every operation
  PYAPPLE_PERMS     mail=read+draft,calendar=all,messages=read,*=read

Tools with nothing left are printed too, marked none. They are the rows somebody reading this output is most likely looking for. pyapple is the command-line front end that ships in the same package — what it is, and what its exit statuses promise.

What each tool loses to *=read

Struck from the operation enum, per tool:

calendar
11 → 6
create, reschedule, alarms, move, delete
reminders
8 → 4
create, update, complete, delete
contacts
6 → 2
add, addTo, rename, delete
maps
7 → 3
save, pin, createGuide, addToGuide
notes
13 → 4
create, append, rename, replace, setBody, folderCreate, folderRename, folderDelete, delete
mail
13 → 7
send, draft, reply, replyDraft, open, markRead
messages
7 → 4
send, schedule, open
all seven
65 → 30
Every tool keeps the operations that only look.

Three of the operations struck here — save, createGuide and addToGuidecannot be performed at all, on any grant. They stay on the surface so that a model asking for a favourite is told why rather than left to invent a route; and listGuides, which is the fourth of those and survives *=read, refuses too.

Absent, not refused

The mechanism is unchanged from the read-only switch, and it is still the point. Forbidden operations are not declined when they are called — they are gone from the operation enum in the tool definition the model reads, so there is no way to express the request at all.

A refusal is something to argue with. A missing enum member is not. And when an agent harness spawns this server directly, the surrounding application never sees the tool calls, so there is nowhere else the rule could have been enforced. The same applies to an operation nobody classified: it raises rather than defaulting to a class that happened to be convenient.

Command line

The same seven tools, with no model in the loop.

pip install pyapple-mcp puts three commands on your path. pyapple-mcp is the server an MCP client spawns and pyapple-mcp-setup writes the config for it. The third is pyapple: the same seven tools, reading the same policy object, driven from a shell. It is what printed the policy above. It is built to be called by scripts rather than read by people, and that single fact decides its whole design — the exit status is the contract.

An empty list and an unreadable store are different answers

--json puts the structured result on stdout and nothing else. A read that returns rows returns them under results, with total and truncated beside them, so a short answer can say it is short. truncated at that level always means rows were left behind; a row whose own text was cut at a character cap says so under a different name — content_truncated, or body_truncated — so the two facts never share a key. When a command fails, stdout is not an empty envelope — it is empty. That distinction is why this front end has a contract at all. It branches on $?, and jq cannot read an apology. Over MCP the same distinction is carried by isError on the tools/call result — and by the same classifier, so the two front ends agree about every failure.

Nothing matched
$ pyapple mail search "roof quote" --json
{
  "results": [],
  "total": 0,
  "truncated": false,
  "more_hint": "limit=-1"
}
$ echo $?
0
Nobody could look
$ pyapple mail search "roof quote" --json
cannot read Mail: …   # stderr
unable to open database file. …
$ echo $?
3

One command, two machines. To anything reading only stdout they are both “no results”: nothing matched is a mailbox with nothing matching in it — and it says so, total 0 and truncated false, rather than leaving a reader to infer it from an empty array; nobody could look is a mailbox nobody could open. A caller that treats them alike reports an empty inbox to somebody whose inbox is merely unreadable — and the explanation goes to stderr, where a pipe will never see it.

Five statuses, and only one of them prints

Every command exits with one of these. The third column is the part worth memorising: success is the only status that writes to stdout, so an empty stdout is never ambiguous.

0
It worked.
the result
1
The operation ran and failed.
nothing
2
Bad command line — including a --since nobody can parse, a --list-name that names no reminders list, or a permission policy that cannot be parsed at all. All three are input you got wrong, not a store that broke; the second used to exit 3, which sent the reader to Privacy & Security over a typo, and the message disproved itself on its face by naming the real lists.
nothing
3
The store could not be read. Usually a permission; on this route usually Full Disk Access.
nothing
4
Refused by the policy, before anything was opened.
nothing

2 is argparse's own status, matched deliberately rather than chosen. The split between 1 and 3 is the one that earns its keep in a loop: 1 is worth retrying and 3 will fail identically until somebody grants something.

The MCP surface makes the same promise, in the field a client reads

A tools/call that failed comes back isError: true. It used to come back isError: false with the reason in content — every tool ended by returning its error as a string, and a returned string is a successful result whose text happens to describe a failure. A client branching on the field, which is what the field is for, read a refusal as an answer. Both front ends now grade an outcome through one classifier, so neither can call a failure something the other calls a success.

0
It worked.
isError false
0
A read that found nothing. The store was read and the finding is zero — an answer, not a failure.
isError false
0
A read that was truncated. The notice saying how much was cut is information about a read that worked.
isError false
1
The operation ran and failed.
isError true
2
A bad request — a missing parameter, a list nobody has.
isError true
3
The store could not be read.
isError true
4
Refused by the policy. Over MCP it never reaches the tool: the operation is struck from the operation enum, so the request fails against the schema.
isError true

The three 0 rows are the half that is easier to get backwards, and breaking them would be the same conflation pointing the other way — a client told a search failed because it matched nothing is as badly served as one told an unreadable mailbox was empty. The refusal text survives either way, because a model acts on it: pass delete_notes=2 to destroy them is not a diagnostic, it is the next call.

Eight commands

The seven tools keep their names — messages, mail, contacts, reminders, calendar, notes, maps — and each takes its operation as a subcommand. The eighth is perms. Operations the schema spells in camelCase are accepted in both spellings here: mail mark-read and mail markRead reach the same operation and the same classification.

Terminal
$ pyapple reminders list --list-name School
$ pyapple mail unread --since 2026-08-05
$ pyapple messages read --chat-id 67 --json
$ pyapple calendar calendars

pyapple <tool> --help lists that tool's operations; pyapple <tool> <operation> --help lists its flags.

Four flags, everywhere

--json
The structured result on stdout, and nothing else — rows under results, with total and truncated beside them. Logs go to stderr so they cannot land in a pipe.
--read-only
Refuse anything that sends, creates, moves or deletes. The documented shorthand for *=read.
--perms
Narrow this one invocation, in the same spelling the config file uses.
--version
Prints 1.5.0 without importing a single macOS framework.

They are accepted before the tool and after the operation both, because pyapple mail unread --json is what people type and being corrected on flag order by a tool that understood you anyway is merely rude.

One policy, two front ends

A permission cannot mean two things depending on which door you came through. The server and the command line consult the same policy object, so PYAPPLE_PERMS set in an MCP config also governs what a shell can do, and --perms only ever intersects with it.

This is pinned as a set rather than promised in a docstring: a check walks every operation the CLI exposes and asserts each one falls on the side of the gate its class says it should. A subcommand named send-message where the schema says send would be a write that read-only mode waved through, and nothing else on the machine would have noticed.

pyapple perms is the reason it is worth having a shell here at all — it answers why was that refused for the server too.

Flags with no argument behind them

Six flags ask for something the tool schema has no way to express, listed so nobody hunts for them there. --all on mail reply and mail reply-draft answers everyone rather than the sender. mail open --select highlights the message in the main window instead of opening its own. mail mark-read --unread marks it unread again, which the MCP operation cannot do at all. mail coverage --mailboxes reports every mailbox, where the schema's mailbox narrows the same report to the ones whose name matches. notes list --limit bounds a listing the schema always takes at its default, and notes folders --include-deleted adds the trash the schema always leaves out.

Everything else that looks shell-only is a spelling: --start is start_date, --to is phone_number, --id is whichever identifier the operation takes. A script comparing the two surfaces finds 21 such renamings and six real ones.

mail reply-draft composes the reply into Drafts instead of sending it, and mail reply --draft is the same thing under the older spelling. Both are the replyDraft operation, so both cost the draft grant: mail=read+draft can answer your mail and cannot post the answer, which is the distinction worth having, since nearly everything an assistant writes is a reply. It cuts both ways — mail=read+send can transmit a reply and cannot file one, because the class belongs to the operation rather than the intent.

Frameworks

The apps stopped opening, and the permission shrank rather than traded.

The user noticed Maps, Calendar and Reminders opening windows during ordinary work and asked whether it could stop — with the condition that an ugly workaround would be worse than the window. No workaround was needed. EventKit, the Contacts framework and MapKit are data frameworks: there is no window to suppress, because there is no app in the loop. Measured with each app quit first.

What each write used to do to your screen

Every one of these reported success before and after. The difference is not in the return value, which is exactly why it went unnoticed for as long as it did — the only way to see it was to quit the app, run the operation, and look.

reminders
launched → not
Every write brought Reminders up. It no longer launches at all.
calendar
5 → 0 windows
A single Apple Event opened five of them.
contacts
launched → not
All three writes, including the ones that were about to be refused for a bad argument.
maps
launched → not
And search now returns real places rather than the string “please manually search in the Maps app”.
notes
unchanged
The exception, and it cannot be fixed — macOS ships no framework for Notes.

check_app_access was its own trigger. Every write called it first — an Apple Event whose only job was to ask an app whether it was reachable, which launched the app in order to find out. It is gone from the tools that no longer need it.

The permission got smaller, not different

This is the part worth being explicit about, because the usual shape of such a move is a trade. CNSaveRequest and EventKit writes are gated by the same privacy grant the reads already held — Contacts, Calendars, Reminders. An Apple Event needs a separate “control this app” Automation grant on top.

So somebody who never asks for an open now never answers an Automation prompt for Calendar, Reminders or Contacts at all. Nothing was given up to get that: the framework route is also the one that can tell you an event does not exist in 0.006s rather than 60.06s, and that can delete the fourth occurrence of a series instead of the first.

Checks assert the property rather than the symptom: contacts.py has no applescript name in it at all, so there is no handle left to reach the runner through, and mail's reads were proven against the real store with a runner that raises on any Apple Event. A window count is not something a check can see; an absent import is.

What still needs Automation, written out rather than summarised

The page used to say “nothing launches an app except open. That is true of contacts, reminders, calendar and maps, and false of the other three — which is exactly the kind of claim that survives because it is nearly right. Here is the whole of it.

notes
everything
Reads included. There is no framework, so there is no other door.
mail
7 of 13
Everything that composes or marks — send, draft, reply, replyDraft, open, markRead — plus mailboxes, which is the one read still asking Mail itself, and the --mark-read flag on unread. Every other read moved off Apple Events, accounts included: it used to ask Mail whether an account was enabled and now reads the same index coverage does, so a question about a local SQLite file no longer starts Mail to answer it.
messages
2 of 7
send and open.
calendar
1 of 11
open — and the invitees argument on create, because EventKit has no public setter for attendees.
reminders
1 of 8
open.
contacts
none
Nothing at all, and a check keeps it that way.
maps
none
Nothing at all. It needs no privacy grant either.

maps pays nothing, and reaches the network

MapKit asks for no privacy grant whatsoever, so maps search and maps directions are the only operations here that need nothing at all. Search went 2.68s → 0.72s, a route lookup 2.23s → 0.12–0.46s, and no window appears — verified by quitting Maps, running a search and three route lookups, and confirming the app was still not running.

It is also the one thing in this package that leaves your Mac other than sending. The remaining latency is a round trip to Apple's geocoder, not Apple Events. And there is no current location — a process that is not a bundled app cannot hold the Location Services grant — so directions always needs an origin, and “from here” is something the caller has to supply.

A window, once opened, is a one-way door

Measured on three apps, and it is why every operation that can show something has show off by default. close window 1 on Maps blocks until the Apple Event times out — error -1712, roughly two minutes — and leaves the window exactly where it was. Mail's compose window resists closing by reference, by index and by id, and hiding and deleting the outgoing message besides; every attempt returns without error and changes nothing.

Reminders is the one exception: its window does close. Everywhere else, opening a window commits the person at the keyboard to dismissing it by hand, which is not a decision an assistant should be making for them.

notes cannot follow, and that is permanent

There is no framework API for Apple Notes. Not a slower one, not a private one — none. AppleScript is the only route in, so notes keeps its Automation grant and will keep it.

What that route costs is visible in the reads. Notes' body is HTML, and on a 59-note store it runs to 13,790,681 characters where the text is 14,463 — a 953× amplification, almost all of it base64 image payloads, sent across the Apple Event boundary and then thrown away by a regular expression. It made search wrong as well as slow: body contains "the" matched 37 of 59 notes against 8 for plaintext, the extra 29 matching inside image data.

Mail

The best-covered tool here was answering about the wrong mailbox.

mail has more checks than anything else in the package and it is the tool we use ourselves, so the prior was good. Four of its operations were still wrong, in the direction that worries people, and all four were found the same way — by driving them against a real mailbox and counting what came back.

Gmail keeps every message once, and it is not in your inbox

A Gmail account stores each message a single time in [Gmail]/All Mail and records its INBOX, Sent and label membership in a separate labels table. So the mailbox column that --mailbox INBOX was matching on holds nothing: All Mail carries 30,919 rows and the two Gmail INBOXes carry zero, while the labels table puts 29,618 of those rows in an inbox.

unread
2 of 6
--mailbox INBOX reported two unread messages to somebody who had six.
search
10 of 1,303
--mailbox Sent found the 10 Exchange sends and none of the 1,293 Gmail ones — which is “did my message ever go out?” answered wrongly.
account
dropped
Accepted and documented, it never reached the query, so naming one account answered about all three. Now 1 / 4 / 0, summing to the unscoped figure.
discards
counted
The exclusion knew Trash and Spam but not Exchange's Deleted Items and Junk Email, so mail you had thrown away was reported as new.

The predicate follows labels now and reports which one matched. An unknown account raises rather than silently widening — an argument that is quietly dropped is worse than one that is refused, because the caller cannot tell that the narrowing did not happen. That is the same argument this package makes about an unparseable since, and about a policy string it cannot read.

“32% of the store” was the mean of a zero and two hundreds

Bodies are not in Mail's index — search matches headers because those are columns — so “find the email with the booking reference in it” had no answer, and searchBodies is the answer. It can only ever be partial. The first audit measured how partial as 14,532 body files against 45,654 indexed rows and reported 32% of the store, though 500 of the 500 most recent. Per account, that is not one number:

dormant IMAP
0% of 31,143
Spans 45 months and ends 14 months ago. Answers no body search at all.
primary IMAP
100% of 14,245
Spans 39 months, to today.
Exchange
100% of 279
Spans four months, because Mail is set to keep four months on that account. Not a defect and not a permission — the IMAP account on the same machine goes back 39 months, and no code here widens it.

“32%” reads as partial-but-usable, and the truth is that the largest account answers nothing. “500 of the 500 most recent” was measured on whichever account happens to be recent. This is the same failure shape the tools themselves were caught in six times over — a figure taken across the thing that works, hiding a zero in the thing the user has. So coverage exists, accounts carries it, and a zero-result body search now says whether it found nothing or read nothing. Decoding the 500 newest bodies is 2.25s and 2,000 is 5.98s, so the budget is a real budget.

Two ways a body read as empty

6.5% of downloaded bodies came back blank — a single-part text/html message hit a branch that handled only text/plain. 26 of 400 sampled, 26 of 26 empty, which is indistinguishable from a message that was never downloaded. After the fix, 27 of 27 non-empty, and the 500 newest messages went from 447 bodies to 500.

And HTML bodies were 55% layout. The contents of a <style> block survived tag-stripping and came back as prose. Two bodies measured 84% and 86% whitespace; one carried about 2,300 characters of text inside 15,980, so against a 10,000-character cap you paid for indentation and lost the end of the message.

An account can be listed and have no mailboxes

One of the three accounts on the measured machine is switched off in Mail. It answers count of mailboxes with 0 while its 20,178 messages sit in the index and come back from every read here.

So somebody listing mailboxes in order to decide what to pass to --mailbox was choosing from a menu that omitted a third of their mail, with nothing on screen to say so. That account now gets a line of its own. It is not a mailbox name and does not pretend to be one; it is the sentence a person needs in order to understand why their mail is in the results and not in the list.

draft, reply and replyDraft

draft is the same compose path as send, stopping one verb short and filing the result in the sending account's Drafts. reply asks Mail to reply, so the threading headers are set and the recipient's client files the answer under the original — verified by conversation id, which the reply shares with the message it answered and a separately composed message does not.

replyDraft is the one an assistant needs most, because nearly everything it writes is an answer to something. Each is its own operation rather than a flag because the policy gates on the operation name: a flag is invisible to the gate, which is exactly how a drafted reply was briefly refused under a draft grant.

from_account chooses which address it comes from. Without it Mail uses its default, and on a machine with three accounts the From: line is the first thing the recipient reads and the last thing the caller could see.

Reported, not fixed: every send leaves a copy in Drafts

Mail autosaves any outgoing message it can attribute to an account. Measured directly, by building a message that was never saved and never sent: it appeared in Drafts within 8 seconds and stayed. That also falsifies this package's own previous claim that an unsent outgoing message is discarded when the script ends.

It is not fixed because the copy appears asynchronously, so an in-script cleanup runs too early — and a later one would mean this package deleting from your Drafts by matching on a subject line. The documentation is corrected; the behaviour is not. That is the trade, stated rather than buried.

What arrived since you last looked

Something that only reads still has to ask what changed. mail and messages both take a since timestamp in ISO 8601 and return only what arrived at or after it.

It is a predicate in the query rather than a filter applied afterwards, so a limit of ten means ten of the new ones. A value that cannot be parsed is an error, never a quiet unfiltered result — which is the difference between a poller and a poller that silently re-reads everything.

“Mark that one as read.”

mail

A message has a settable read status, so markRead marks the one you name with no window and nothing brought to the front. open shows a specific message instead of gesturing at a mailbox, and sets the flag explicitly rather than relying on the side effect — opening a window marks a message read, and merely selecting it does not.

messages

There is no read state to set. A chat exposes exactly four properties — id, account, class, name — and both unread count of chat and set read status of chat to true are syntax errors, not permission failures. So the operation is called open: it displays the thread, and the badge clearing is a consequence of that.

Writing is_read = 1 into chat.db was considered and refused. That file belongs to a running app that holds it open, keeps its own cache and owns a -wal beside it — every read here opens it read-only for exactly that reason — and the badge is Messages' state rather than a direct read of the column, so the write would risk the store for an effect that probably would not appear until a restart. Anyone expecting the two tools to behave alike would otherwise conclude the Messages side is broken. It is the platform that is asymmetric, and the operations are named for what each one can actually do.

Honest failure

A good deal of the recent work is a refusal.

Seven tools were driven against the real apps by four agents writing the requests a person would actually make. Almost every operation nobody had ever driven turned out to be broken, and every one of them reported success. So the fixes divide in two: things that now work, and — more of them — things that now say plainly that they cannot. A tool that returns success: True for having launched an app is worse than a tool that is missing, because the caller has no way to find out.

maps was written against a vocabulary that does not exist

Maps.app ships no scripting definition at all — nothing in the bundle, nothing in its Info.plist — so every one of the seven operations was AppleScript aimed at a dictionary macOS has never had. What each of them did, measured on the previous implementation, one run each:

search
2.683s
Activated Maps, returned success: True and the string “please manually search in the Maps app”.
directions
2.228s
Activated Maps, returned success: True, and suggested the caller search by hand.
listGuides
0.796s
Activated Maps, success: True, no guide data.
createGuide
0.724s
Activated Maps, success: True, created nothing.
save
−2741
An AppleScript syntax error. It had never executed a single line, for anyone, since it was written.
pin
−2741
The same.
addToGuide
−2741
The same.

search and directions go through MapKit now and answer properly. Favourites and Guides are permanently impossible: no MapKit API exists for either and there is no readable copy on disk — the container's Maps directory is empty and the data is CloudKit-backed and opaque. So save, listGuides, createGuide and addToGuide report failure and hand back a durable maps.apple.com link an assistant can file in a note instead. listGuides in particular must never answer with an empty list: a store that cannot be read has to stay distinguishable from a store with nothing in it. pin does work, through that same link.

Notes cannot be edited safely, and 0 of 56 pass the gate

Notes' HTML serialiser and its HTML parser do not agree, so the body it hands you is not a body it will take back. Notes offers no partial edit, so every change is a whole-body rewrite, and the losses are invisible afterwards — an image is not in the trash, it is gone.

attachments
2 → 1 → 0
Under set body of n to (body of n) — a pure identity write, changing nothing.
images
2 → 0
On the first write, in three clean trials, returning success every time.
links
text only
<a href="x">t</a> goes in and <u>t</u> comes out: the anchor text survives, the URL is dropped.
headings
5 of 5
Five input forms of <h1> were tried; every one stored back as a bold span.
checklists
10 of 10
Ten candidate markups, all stored back as a plain bullet list with every tick cleared.

So rename, replace, setBody and append refuse any note carrying that content. On the development store that is 0 of 56 notes — 40 carry an attachment, 36 an image, 19 a heading, 13 a link, and 6 are password protected. That is the honest number, and it beats an editor that works on plain notes and eats the rest. Notes this tool creates always pass.

There is deliberately no override flag. A flag saying “yes, lose the images” would be set by a model that has never seen the note, on behalf of a user who has.

messages schedule cannot work, so it refuses

Messages' scripting dictionary declares three commands and none of them takes a date. The words “schedule”, “later” and “delay” do not appear in it anywhere. The database has columns that look like a way in until you notice that all fifteen rows carrying one also carry a send error.

It refuses, and a check pins that it never quietly sends immediately instead — which is the failure that would actually hurt. It stays on the tool surface rather than being removed, because a model asked to send something at seven in the morning, finding no such operation, is likely to reach for send.

One contact card in nine cannot be written at all

20 of 179 real cards refuse every framework write with Cocoa error 134092 — 11.2%, and stable per card rather than intermittent: 5 of 5 attempts fail on an affected card, 0 of 5 on each control. Measured over the whole address book, one card at a time, each change applied and then restored to its exact recorded original.

Nothing the API exposes separates the two groups. Ruled out by testing rather than by assuming: the container, the fetch key set, staleness, unification, linked cards, images, and the kind of change. So it cannot be pre-flighted and retrying is pointless. The tools refuse and name the reason, including the fact that the Contacts app can still edit the card — because the framework's own message is “The operation couldn't be completed”, which invites a retry that will fail forever.

The one good property, verified: the failure is total, never partial. Every affected card was byte-identical afterwards.

Three findings about the instruments, not about the product

Each was found while trying to verify something else, and each invalidates a verification rather than a feature — which makes them the more expensive kind. Two are about a call that answers true for having done nothing; the third is about an average.

A no-op save returns true and writes nothing. The first two sweeps of the address book used a no-op — writing back a value already there — as the safe probe, and reported zero failures across 20 real cards. executeSaveRequest returns true for a save whose values all equal what is stored. A no-op cannot tell a writable card from an unwritable one, so any verification built on one proves nothing, and the two measurements it produced were worthless. That is also why contacts rename refuses a rename that would change nothing, rather than reporting a success it did not earn.

And executeSaveRequest returns true for an already-deleted contact, so its success is not evidence that anything existed. Matching happens before the save for exactly that reason.

A third of the same kind, found a day later and described above: the mail-coverage figure this site was about to publish, 32%, was the mean of one account at 0% and two at 100%. Every one of these three is a measurement that agreed with itself and described nothing.

A command that never ran, and the check that was content

mail search-bodies crashed on every invocation from a shell. The renderer takes two arguments and the command passed one, so it raised after doing all of the work and exited 1 — not “returns empty”, never ran. It was wired an hour before it shipped, and a model trying to build a timeline out of a mailbox filed two of its six complaints against it.

The reason it got through is the interesting half. The parity check between the two front ends compares operation names, and the name was right — so it was perfectly content with a command wired to a function it could not call. There is now a check that invokes all 24 leaf commands against a stub. It cannot catch a wrong query or a wrong script; it catches exactly this.

Two renderer bugs went the same way inside one hour: mail coverage read three keys the handler does not produce, so every row printed None, and then formatted a value already in percent as a percentage and printed 10000%. A stub that invents its own payload passes both. The check that catches them uses the handler's real key names.

Unknown is not failure

A 120-second sweep returned success: False with the messages already marked. Timeouts now carry a distinct outcome meaning unknown, possibly partial, and it is raised rather than returned — a key on a dictionary can be dropped by writing if not result["success"], which is the line that produced the false report.

It proved itself in the wild before the release shipped. A send attempted while the machine sat at load average 321 was cut off, and reported that the outcome was unknown and that Sent should be checked before retrying. Sent showed nothing had gone, so the retry was safe — which is exactly the decision success: False makes impossible.

Scope is a requirement, not an optimisation

whose read status is false over a 45,000-message Gmail All Mail does not finish. Scoped to a single mailbox, the same query is 1.6s.

So an unscoped sweep is refused immediately, naming the mailboxes on this machine that you could scope it to. Offering a call that cannot work, and answering it two minutes later with an outcome nobody can report, is worse than saying no.

A verification here was wrong before it was right: the sent count was compared six seconds after a send and showed nothing, and fifteen seconds later it had moved. A check whose timing is wrong reports the same thing as a failure.

Two control characters cannot be stored, and it says so

U+001E and U+001F are how a refusal marks a parameter name, so that one sentence written once prints as --delete-notes at a shell and delete_notes to a model. Text arriving with them in it gets the same treatment. Measured: a reminder named shop ␞account␟ now was stored verbatim by EventKit and came back out as Created reminder 'shop --account now' — a flag this tool never offered, written by whoever typed the reminder.

They are now removed from every argument of every operation before anything is stored or matched. That was already happening on the AppleScript routes, as a side effect of there being no literal form for a control character — which is why notes and mail were incidentally safe and the EventKit, Contacts and MapKit writes were not.

The second half is that it is no longer silent. Storing text that differs from the text you sent and reporting plain success is the same fault as a truncated read that does not mention it: the write goes through, and the answer carries a count of what was removed and from which parameter — on stderr for a shell, in removed_control_characters under --json, and appended to the text an MCP client receives. Nothing else is touched; quotes, backslashes, emoji and bidirectional marks are stored exactly as sent.

Observed, and not a defect here

At that same load average of 321, every Apple Events path became unusable — Mail sat at 0% CPU in a runnable state, starved rather than broken — while the reads that go straight to the local index were unaffected, answering in 0.07s to 0.49s throughout.

Which is the three-routes picture from the top of this page, showing up as latency instead of as a permission dialog.

In practice

You ask in words. It picks the tools.

There is no syntax to learn. These are ordinary requests; the marks underneath name the tools the assistant reaches for, each in the colour of the permission it needs. The stripe down the left of each card is the most any one request will ask for. Colour on this page is always the route into your Mac — whether a request may send anything is the policy's business, and it is not a colour.

  • What's on my calendar Thursday, and is anything overdue in Reminders?

    calendar reminders

  • Where am I actually free for an hour this week? Ignore the birthdays.

    calendar

  • Pull up my notes on the migration and write me a one-paragraph summary.

    notes

  • Anything unread from the landlord? Reply in that thread that Saturday works.

    mail

  • Find the email with the booking reference in it — it's in the body, not the subject.

    mail

  • Write the answer to the recruiter but don't send it — I'll read it first.

    mail

  • How long from the office to the airport if I need to be there by three?

    maps

  • Text my sister that I'll be twenty minutes late.

    contacts messages

  • Add “renew the library books” to my errands list for Friday, and remind me an hour before.

    reminders

  • Tick off the two things I finished this morning.

    reminders

  • What did she say about the address? Search the messages, don't make me read the thread.

    messages

  • Find the group chat my brother and my sister-in-law are both in, and catch me up on it.

    contacts messages

  • Put the standup on every Tuesday and Thursday until the end of term.

    calendar

Two of those are worth the extra sentence. “Where am I free” is a question about gaps, and a list of bookings cannot answer it — least of all here, where 211 of the next 365 days on the development machine carry an all-day holiday or birthday that a naive reading would count as a full day booked. And “by three” is a different question from “how long”: it is answered backwards from the arrival time, with traffic.

Develop

Run it from a checkout.

The server is a single module built on FastMCP. Clone it, install the dev extras, and point the MCP Inspector at the installed command to watch the tool calls go past.

Local checkout

Terminal
$ git clone https://github.com/54yyyu/pyapple-mcp.git
$ cd pyapple-mcp
$ pip install -e ".[dev]"
$ python -m pyapple_mcp.server

Inspect and check

Terminal
$ npx @modelcontextprotocol/inspector pyapple-mcp
$ pytest   # 840 checks

black, isort and mypy are configured in pyproject.toml and run over the same tree. A check here is not trusted until it has been made to fail — the line it covers is deleted, the suite is confirmed red, and the line is put back. Several rounds of that found checks that were green against the bug they were written for, which is a claim of coverage rather than coverage.