v1.4.0 — permission classes, honest write outcomes, quiet drafts
Permissions stop being all-or-nothing, write paths stop lying about what they did, and a draft stops taking over your screen.
Per-tool, per-class permissions
PYAPPLE_READ_ONLY=1 stripped writes from all seven tools or none. The real requirement is finer in two directions at once — per tool, and per operation class within a tool. The interesting line is not read-versus-write; it is "changes my Mac" versus "leaves my Mac and reaches another person." Deleting a note is private and undoable. Sending an email is neither.
Four classes: read, write, draft, send.
PYAPPLE_PERMS="mail=read+draft,calendar=all,messages=read,*=read"
One string, identical in an MCP config's env block and in a shell. --perms on the CLI intersects with the environment — it can subtract and never add, so a caller that reached a terminal cannot undo the restriction its config set. A malformed policy raises at startup rather than falling back to permissive; a policy that silently fails open is worse than none. PYAPPLE_READ_ONLY=1 still works, as the documented shorthand for *=read.
Forbidden operations are absent, not refused: each operation parameter is a JSON Schema enum, so a model never sees an operation it cannot call. Under *=read the surface goes from 42 operations to 22, measured against the running server.
pyapple perms prints the effective policy and where each part came from, because the first question after a refusal is always why.
messages has no draft class and that is deliberate — iMessage exposes no draft object to AppleScript, so for that tool the choice really is read or send. Inventing a draft tier that silently sent would be the worst outcome available.
A write that times out no longer reports failure
A 120-second mark_as_read returned "timed out" and success: False after having already marked the messages. The failure message was false, and a caller that retries on failure would repeat a completed mutation — or send a second copy of an email.
A timed-out write now returns a distinct outcome: unknown, possibly partial. Never success: False. Reads still report a timeout as failure, because nothing was changed. This proved itself in the wild during testing: a send was cut off under load average 321 and correctly reported "unknown — check Sent before retrying" rather than inviting a duplicate.
Unscoped sweeps are also gone. whose read status is false over 45,000 messages does not finish; scoped to one mailbox it is 1.6s. Scoping is a requirement, not an optimisation, so mark_as_read refuses without one and names the mailboxes worth passing.
Mail gained four operations
draft, reply, open and a targeted markRead, plus from_account and since.
reply uses Mail's own reply verb rather than composing a lookalike with "Re:" in front, because that verb is what sets In-Reply-To and References — a hand-built imitation reads like a reply and files like a stranger.
Drafts are quiet now
draft files into the sending account's Drafts and opens no window. show is opt-in.
The reason it stays that way is not tidiness. A compose window Mail has opened cannot be retracted from AppleScript. Closing by reference, by index and by id, hiding the outgoing message, and deleting it all return without error and leave the window on screen; closeable reports true throughout. Opening one commits the person at the keyboard to dismissing it by hand.
Verification
268 automated checks. Every new check was made to fail before it was trusted — 43 mutations, each reverting one fix, all 43 detected, tree restored green. Driven against the real Mail and Messages apps throughout rather than only against fixtures.
Full reference: https://54yyyu.github.io/pyapple-mcp/api.html