Skip to main content
Glama

Server Configuration

Describes the environment variables required to run the server.

NameRequiredDescriptionDefault

No arguments

Instructions

Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.

This server publishes no instructions, or was last inspected before Glama recorded them.

Capabilities

Features and capabilities supported by this server

Protocol revision2025-11-25

CapabilityDetails
tools
{
  "listChanged": true
}
logging
{}
prompts
{
  "listChanged": false
}
resources
{
  "subscribe": false,
  "listChanged": false
}
extensions
{
  "io.modelcontextprotocol/ui": {}
}
experimental
{}

Tools

Functions exposed to the LLM to take actions

NameDescription
pingA

Health check — confirms macos-apps-mcp is alive. No permission needed.

doctorA

Diagnose per-surface macOS permissions + health with exact remediation.

Read-only and prompt-free by default. request=True also triggers permission prompts (EventKit consent + per-app Automation probes) — use it once to grant.

nowA

Current local date, time, timezone, UTC offset, weekday. No permission needed.

Call this FIRST to ground any relative date ("today", "tomorrow", "next Friday") — never guess the date from memory. Every date parameter on the write tools is interpreted in THIS timezone (naive ISO = local time).

auditA

Recent write audit entries (newest first) — what macos-apps-mcp changed, with before/after pointers, enough to reverse a change by hand. since optional ISO datetime (call now() to ground it) drops older entries; bounded to the last 50. Each entry carries dry_run (resolved from the tool's default when the caller omitted it, so a preview reads true) and caller (the MCP client's name and version, plus session for a shim); caller is reported by the client, never an identity check. Read-only; no permission (reads a local log at ~/.local/state/macos-apps-mcp).

usageA

Per-tool call frequency, for pruning rarely/never-used tools. Returns tools (each {tool, count, first, last}, busiest first), never_used (registered tools with zero calls — the pruning list), and total_calls. Read-only; no permission (reads a local log at ~/.local/state/macos-apps-mcp).

remindersA

List reminders as pointers in {results, coverage?}. due: today | overdue | this-week | a list name. Each result may carry folder (its list's id, from reminder_lists; pass it as list_name=), tags and parent (the parent reminder's id, for a subtask). Requires EventKit and Full Disk Access (tags and parent links come from the Reminders store). Tags and subtasks are read-only: macOS has no public API that writes them, so no tool here can add a tag or nest a reminder — do that in Reminders.app. If the Reminders store cannot be read, results still come back and coverage says why tags and parent links are missing. Read-only. Hydrate none — pointers only.

eventsA

List calendar events as pointers. when: today | week | YYYY-MM-DD. Each result's folder is its calendar's id (from calendars); pass it to free_busy(calendars=…) or as calendar=. A read cannot tell that a series' first occurrence is extra (each result holds its own occurrence start); the create/update result says so. Read-only; needs EventKit (Calendar) access.

free_busyA

Availability in a window: merged busy intervals + free gaps. start/end are ISO-8601 datetimes (naive local, e.g. 2026-07-20T09:00:00); calendars optional Pointer ids (from calendars) to restrict to, else all. Returns {"busy": [...], "free": [...]} of {start, end} — no event details. Read-only; needs EventKit.

reminder_listsA

List reminder lists as pointers (id + name); use a name to target writes. The id is the folder that reminders carry. Read-only; needs EventKit (Reminders) access. See create_reminder to write.

calendarsA

List calendars as pointers (id + name); use a name to target writes. The id is the folder that events carry. Read-only; needs EventKit (Calendar) access. See create_event to write.

contactsA

Find contacts by name (substring). Returns pointers (id + name/org). Read-only; needs Automation access for Contacts. See create_contact to write.

mailA

Search the Mail inbox by subject OR sender substring. Pointers: id = the stable RFC822 message-id, summary = subject — sender, deeplink = a message:// URL, folder = "inbox" (this scans the inbox only) — pass that folder straight to mail_body. Prefer mail_search, which reaches every mailbox and reports the account. Returns {results, truncated?} — truncated means the 25-message cap was reached and there may be more. Read-only; needs Automation access for Mail. Bodies are never fetched.

mail_bodyA

Full plaintext body of one message by id (bounded + truncation-marked).

id is a message-id from mail/mail_search — or from a note written months ago: an id resolves ON ITS OWN, so a stored citation still works after the message was filed or moved. mailbox is OPTIONAL and is the disambiguator: pass the folder value from the SAME search result back VERBATIM — it is an opaque round-trip token (imap://<uuid>/<path>), not a folder name to retype — or one of the five canonical names ("inbox"/"sent"/"drafts"/"trash"/"junk"). With it the read is Automation-only and an id absent from that mailbox raises; without it the id is resolved through Mail's index (Full Disk Access) to the same copy every other read cites. Read-only.

mail_bodiesA

Plaintext bodies for up to 20 message ids in ONE call — the bulk read behind "catch me up on this thread". Opt-in and bounded: reading a thread stays mail_search -> mail_thread -> mail_bodies, three calls total, and with mail_thread(snippets=True) often only two.

Returns {"results": [{"id", "body"}], "missing": [ids]}. An id whose message can't be read off disk is listed in missing — it is NEVER returned with an empty body, so "unreadable" is never mistaken for "the sender wrote nothing". Each body is truncated at the usual per-body budget with an explicit marker; if the batch total is too large the call REFUSES (output_overflow) rather than dumping — ask for fewer ids.

Reads Mail's message files at rest: fast, no Mail launch, and it sees messages that arrived seconds ago (unlike the mail_search(body=) FTS index, which only knows what mail_index_bodies last swept). Read-only. Needs Full Disk Access.

mail_attachmentsA

List attachments on messages in a Mail mailbox, or on ONE message (Automation).

mailbox: the folder value from a mail_search result passed back VERBATIM (an opaque imap://<uuid>/<path> token, not a name to retype), or a canonical system mailbox — "inbox" | "sent" | "drafts" | "trash" | "junk" (resolved via Mail's unified, cross-account accessors). query: optional subject substring — an empty/omitted query lists ALL messages in the mailbox (bounded), unlike mail/get_pointers which rejects an empty query. Use this to confirm an attachment landed on a DRAFT before it's saved (a freshly opened compose window has no stable id yet — once saved to Drafts it does, see drafts()). message_id: address ONE message instead of scanning; with no mailbox the id resolves on its own (Full Disk Access), and query is then ignored. Prefer it when you know the message — a mailbox scan stops at 25 records, so the one you meant is often past the cap. Either mailbox or message_id is required. Returns {results, truncated?} over [{id, deeplink, folder, summary, attachments: [{name, size, downloaded}]}]. id/deeplink identify the carrying message and folder names the mailbox read, so a row is actionable on its own. An unsaved draft has no message-id yet: its id is "" and it carries no deeplink — it is still listed.

mail_needs_responseA

Inbox messages that likely need your response, ranked with a machine-readable reason (flagged / unread-direct / unanswered-direct). Heuristic over headers + message properties — no body is read; keeps direct-addressed, not-yet-replied mail. Each pointer carries folder = "inbox", so an id from here goes straight into mail_body/mail_reply with no second lookup. No account: this reads Mail's unified inbox across every account, so the owning account is genuinely unknown — use mail_search when you need it. Returns {results, truncated?}; truncated means the 25 cap was reached. Read-only; needs Automation access for Mail.

mail_awaiting_replyA

Messages YOU sent more than days ago (1–365, default 3) with no reply, ranked oldest-first, reason awaiting-reply. Uses real In-Reply-To/References threading. A group send is cleared once any recipient replies. Each pointer carries folder = "sent" — ready for mail_body/reply_all; no account (the scan spans every account, see mail_needs_response). Returns {results, truncated?}; truncated means the 25 cap was reached. Read-only. Fast path reads Mail's index at rest (needs Full Disk Access; never launches Mail); without it, degrades to an AppleScript scan — Automation access is the floor, and that path is SLOW on a large store.

mail_searchA

Indexed search across ALL mailboxes via Mail's Envelope Index — fast and read-only. All filters optional and ANDed: subject/from_/to substrings, mailbox a mailbox NAME exactly as mail_overview reports it ("Junk E-mail" — case-insensitive substring; the encoded spelling also works; a name that matches nothing RAISES — it is a typo, not an empty mailbox — while a stale folder url just returns no results), since/until (epoch seconds on received date), unread/flagged. body searches message TEXT via the FTS index and is BEST-EFFORT — it only sees messages already indexed by mail_index_bodies (run that first; incomplete coverage is normal). At least one filter required. Returns citable Pointers, newest first. Falls back to AppleScript inbox search on missing Automation access / schema drift. has_attachments means a real DOCUMENT — inline signature/newsletter images are excluded, so it will not match a mail whose only attachment is a logo. account takes a display name ("Personal") or a raw account UUID; the UUID form stays pure sqlite, while a NAME has to be resolved through Mail and therefore LAUNCHES Mail if it isn't running (an unknown name raises rather than guessing). Every other filter reads the index at rest and never launches Mail. Read-only; needs Full Disk Access, plus Automation access for Mail on the account-name path and the AppleScript fallback. Each Pointer carries folder (the round-trip mailbox token) and account (the owning account's uuid — map it to a name with one mail_overview call), so a hit is complete on its own: no second read to work out where it lives. Returns {results, truncated?, plane?, coverage?}. truncated means the answer came back AT limit (25 max) and there may be more — do NOT report such a search as exhaustive. plane = "applescript-inbox" means the sqlite index was unreachable and this scanned the INBOX ONLY. coverage explains an empty body= answer: bodies are only matchable once mail_index_bodies has indexed them.

mail_threadA

Every message in the conversation containing id, oldest-first — the transcript, including messages YOU sent. Deduped: a message filed in several mailboxes appears once. Returns {results, truncated?} of citable Pointers. Over limit messages the OLDEST are dropped (and truncated says so), since a thread is usually read to reply to it. Unknown id returns no results.

snippets=True adds a short snippet (first ~200 chars of the body) to each pointer, read off disk in one pass — no Mail launch and no extra calls. Use it to see WHO SPOKE LAST and roughly about what before deciding which messages are worth hydrating; then mail_bodies(ids) for the few that are. A message whose body can't be read carries no snippet key at all (absent, not empty). Leave it off for a long thread you only need to enumerate.

Fast, read-only, no Mail launch. Needs Full Disk Access.

mail_overviewA

Every mailbox with its message total and unread count, unread-first — the triage entry point ("what's unread where?"). Rows are {account, account_id, mailbox, folder, total, unread}: account_id is the same uuid every mail Pointer reports as account, so ONE call here is the whole uuid -> name map; folder is the exact mailbox url, so a mailbox picked here round-trips into mail_search/mail_body without going back through name matching. Includes Junk/Trash/All Mail so you can see what they are rather than having them silently filtered. Counts are computed live, not read from Mail's own stored counters, which go stale, and each message is counted ONCE even when it is filed in the same mailbox twice. Account NAMES are looked up through Mail, so this tool does contact Mail (launching it if it isn't running); when Mail is unreachable the account UUID stands in and the counts — which never needed Mail — are still correct. An account Mail answers but does not list (for example a removed one) shows "(account not in Mail)" as its account; account_id keeps the uuid. The On My Mac store is always shown as "On My Mac": Mail never lists it as an account. Fast and read-only. Needs Full Disk Access for the counts and Automation access for Mail for the account names.

mail_index_bodiesA

Build/refresh the opt-in FTS body index used by mail_search(body=…). Reads every .emlx file at rest, .partial ones included (never launches Mail, never writes in Mail's data). Resumable and size-capped — safe to re-run; a re-run continues where it left off. rebuild=True re-indexes from scratch. Returns {indexed, skipped, total_emlx, capped, indexed_this_run}. Read-only for Mail; the one thing it writes is this server's own FTS sidecar. Needs Full Disk Access (it reads .emlx at rest), NOT Automation.

indexed_this_run is THIS RUN's progress, NOT coverage: a resumable indexer's own counters cannot say how much of the store is searchable (a fully-indexed store re-runs as "14 newly indexed"). mail_search(body=…) is the one place that answers "is body search usable?", and it does so on the path that needs it — an empty result.

A .partial.emlx is missing its ATTACHMENTS, not its body, so partials are indexed like any other message (#119). The residual unsearchable tail is ~0.5%: messages Mail never stored a text part for.

mail_index_idsA

Build/refresh the Message-ID sidecar that enables the sqlite mail plane on macOS 15 (Sequoia) and earlier (#201). Those systems' Envelope Index never stored the RFC822 Message-ID (#199); this harvests it headers-only from the .emlx files at rest into this server's own sidecar — never launches Mail, never writes in Mail's data. On macOS 26+ the index has the column natively and this build is unnecessary. Safe to re-run: a re-run harvests only what is missing and retries messages whose files had not been downloaded yet; rebuild=True starts from scratch. A full first build reads every message's headers — about a minute per 35k messages on a slow disk. Returns {harvested, mapped, total_ids, coverage, skipped_no_file, skipped_no_message_id, rows_without_local_store, high_water_rowid, index_rebuilt}. coverage is the number that matters: messages it could not map stay uncitable, exactly as they would be on macOS 26 until Mail backfills its own column. Read-only for Mail; needs Full Disk Access (it reads .emlx at rest), NOT Automation.

mail_statsA

Mail volume, read ratio and top senders over the last days (Full Disk Access).

Pure Envelope Index — never launches Mail. Counted per DISTINCT message, not per row: the same message filed twice is one message (raw rows overcount by up to 3.6x on a real store). account takes a raw account UUID — the account field every mail Pointer and mail_overview row carries — not a display name.

Returns a compact aggregate: {window_days, since, messages, unread, read_ratio, flagged, with_attachments, per_day, busiest_day, top_senders[10], top_mailboxes[10], plane}. Deliberately token-bounded: top-N only, no per-day series. top_mailboxes carries the decoded name plus folder (the round-trip url) and the account uuid — map the uuid to a name with one mail_overview call. Read-only.

export_mailA

Write messages out as importable .eml files (Full Disk Access).

Read AT REST — the same on-disk bytes the undo backups copy — so this never launches Mail and needs no Automation. ids are RFC822 message-ids from a mail read, comma-separated, max 25. dest_dir must be inside the allowed root (~/Downloads by default; MACOS_APPS_FILE_ROOT moves it) and is created if needed; a path outside it is REFUSED. Filenames are derived, never overwritten. A name that already exists refuses the whole batch before any file is written; a refusal after a write names the files already written.

.eml is the only format — it is lossless and opens in Mail and everything else, and mail_body already covers "give me the text".

Returns {results, written, dest_dir, plane}. A message with no local file is reported per-id as status: "absent" rather than failing the batch. fidelity: "partial" means the .eml is missing its ATTACHMENT payloads — the body is complete (#119). It is lossy for archival, not for reading. Additive: writes new files, never overwrites or modifies mail.

save_mail_attachmentA

Save ONE attachment to disk (Automation, plus Full Disk Access to resolve a bare message_id).

List them with mail_attachments first, then address one by name or — when several on the same message share a name, which is ordinary — by its attachment_id (the id field on the attachment row). An ambiguous name RAISES and lists the ids rather than picking one.

dest_dir must be inside the allowed root (~/Downloads by default; MACOS_APPS_FILE_ROOT moves it) and is created if needed; a path outside it is REFUSED. The filename is DERIVED from the attachment's name — an attachment name comes from whoever sent the mail, so it is treated as hostile — and an existing file is never overwritten. Writes are capped at 25 MiB.

mailbox is optional: the folder value from the read that produced message_id, verbatim. Omitted, the id resolves on its own through the index.

Saving an attachment Mail has not downloaded makes Mail FETCH the message first, so this can take much longer than a read; if the account is offline the file lands empty, and it is then removed and reported as a failure rather than left behind. Returns {saved, name, original_name, bytes, reported_size, was_downloaded, id, folder}. Additive: writes a new file, never modifies mail.

create_draftA

Create a Mail draft and OPEN it for you to review and send — it NEVER sends on its own. to a recipient address. Returns a locator dict ({"created", "subject", "mailbox", "from", "note"}) — a freshly opened compose window has no stable id yet, so this says where to find it (Drafts) instead of fabricating one; save it and drafts() resolves it by its stable message-id. If the create FAILS partway, Mail may still leave a stray autosaved draft behind (#133 — its autosave is asynchronous and cannot be suppressed); drafts() + delete_draft() clear it.

A new draft is unthreaded; it is sent from from_address (an address one of Mail's accounts owns, matched case-insensitively, Name <addr> accepted) or Mail's default account. An address no account owns is refused before Mail builds anything — Mail would otherwise use its default account silently. To answer a message use mail_reply, which is threaded and sent from the account that received it.

THE AUTOSAVE WINDOW: Mail stamps the Message-ID only when it autosaves the draft, ~10-15 SECONDS after this returns — asynchronously, and nothing can hurry it. So drafts() called immediately shows nothing. That is normal. Wait for the window, then drafts() resolves it. Do NOT retry this call because the draft has not appeared: you get two drafts and the first one still arrives. Additive (creates a draft; does not send/modify/delete); needs Automation access for Mail (the ownership read is Automation too).

mail_replyA

Reply to a message, opening a threaded draft for review (Automation).

NEVER sends. message_id: the RFC822 id from a mail read. mailbox is REQUIRED: the folder value from the SAME search result, passed back VERBATIM (an opaque imap://<uuid>/<path> token; the five canonical names also work). Mail sets the threading headers natively; include_quote appends the quoted original. Returns a locator dict — save the draft to Drafts and drafts() resolves it by a stable message-id.

THE AUTOSAVE WINDOW: that message-id is stamped only when Mail autosaves the draft, ~10-15 SECONDS after this returns — asynchronously, and nothing can hurry it. So drafts() called immediately shows nothing; that is normal, not a failed create. Wait for the window, then drafts() resolves it. Do NOT retry this call because the draft has not appeared: you get two drafts and the first still arrives. Note a reply draft cannot be sent with send_mail(draft_id=…) — rebuilding it would drop the threading headers, so that call refuses and points you at reply_all(dry_run=False) instead.

draftsA

List Mail drafts, newest mailbox order. Returns {results, truncated?}; truncated means the 25 cap was reached. Each record is a citable pointer (id, summary, deeplink, folder = "drafts") PLUS discrete subject and to fields, so picking out one specific draft is a field comparison, not substring-matching the summary — which collides whenever two drafts share a subject. The id is the RFC822 message-id: pass it to delete_draft, or to send_mail(draft_id=…) to send a draft the human has approved. A compose window Mail has not autosaved yet (~10-15s) is absent here — wait, do not retry the create. Read-only; needs Automation access for Mail.

delete_draftA

Delete one Mail draft by its message-id (from drafts()). dry_run DEFAULTS TO TRUE — previews the draft that WOULD be deleted (pointer, no mutation); pass dry_run=false to delete. Destructive but LOCAL — this deletes an unsent draft, it never sends. Needs Automation access for Mail.

create_mailboxA

Create a Mail mailbox (folder) under one account. name may contain "/" to nest ("Projects/2026") — missing parents are created for you. account is a display name ("Personal"), an account UUID, or "On My Mac", and is REQUIRED: a folder has to land somewhere specific.

Returns {created, mailbox, account, folder, note}. folder is the address token to pass back verbatim to move_mail/mail_search; it works immediately, and mail_overview will report Mail's own equivalent spelling of it once the account syncs — both address the same mailbox. A "%" in the name is rejected (it cannot be addressed unambiguously). There is no delete counterpart: removing a mailbox is not scriptable, so do it in Mail. In a Gmail account the new mailbox is a label: move_mail refuses it at either end (#291), and reads show it empty while Mail's index does not mark it as a label. Additive (creates a folder; touches no message); needs Automation access for Mail, plus Full Disk Access to resolve "On My Mac".

move_mailA

Move messages between Mail mailboxes — archive, file, or refile. DESTRUCTIVE but recoverable: every message's bytes are copied to a backup directory and its source mailbox is logged BEFORE anything moves, so mail_undo puts the batch back.

dry_run DEFAULTS TO TRUE — the preview reads Mail and reports, per id, whether it is actually present in from_mailbox. Pass dry_run=False to move. ids are RFC822 message-ids from a mail read, comma-separated; max 25 per call and the cap is not overridable. BOTH mailboxes are required and are address tokens. from_mailbox must be the folder value from the read that produced the ids, passed back VERBATIM (an opaque imap://<uuid>/<path> token, not a name to retype); a canonical name there is refused. to_mailbox may also be one of the canonical "inbox"/"sent"/"drafts"/"trash"/"junk", which files into the SOURCE message's own account (it names Mail's cross-account accessor, not one mailbox), so it is refused for an On My Mac source — that store has none of the five. A Gmail label folder is refused as either end (a move out of a label left a copy on device, so mail_undo cannot reverse a move into one), and a canonical to_mailbox is refused when the source account has Gmail label folders. Pass a folder url from mail_overview when in doubt. To archive, move into a mailbox named Archive — there is no separate archive tool. Cross-account moves are supported and leave exactly ONE copy (out of a Gmail label they do not, which is why a label is refused). Verification is by-ID (#206): each copy's internal reference must go dead after the move, and the destination's count of that Message-ID must rise — presence alone can't tell a landed copy from a pre-existing one. A Message-ID with several copies in the source gets every one of them moved; ok means every copy left. A per-id status reports what really happened rather than assuming success. If the call times out mid-batch, the error names the receipt — re-run the same batch, or mail_undo it. Returns {op, receipt, count, succeeded, targets, destination, backup_dir, undo} — keep receipt to undo the batch. Needs Automation access for Mail, plus Full Disk Access to locate each message's file for the backup.

trash_mailA

Move Mail messages to Trash — soft delete, and the ONLY delete there is.

Mail's scripting layer cannot permanently erase a message: delete moves it to the owning account's Trash, and nothing in the dictionary empties Trash from a script. Emptying Trash is something the user does in Mail.app. So this is always recoverable, and there is no permanent-delete tool to ask for.

DESTRUCTIVE but recoverable: every message's bytes are copied to a backup directory and its source mailbox logged BEFORE anything is deleted, so mail_undo moves the batch back out of Trash. dry_run DEFAULTS TO TRUE — the preview reports, per id, whether it is actually present in mailbox. Pass dry_run=False to delete. ids are RFC822 message-ids from a mail read, comma-separated; max 25 per call and the cap is not overridable. mailbox is REQUIRED and must be the folder url from the read that produced the ids, passed back VERBATIM — not one of the canonical names: Mail files a deleted message in its OWNING account's Trash, and a unified name ("inbox") cannot say which account that is. A Gmail label folder is refused (a delete there is not a delete of the message, #287); use the message's [Gmail]/All Mail folder. Returns {op, receipt, count, succeeded, targets, destination, backup_dir, undo} — keep receipt to undo the batch. Each copy is deleted by Mail's internal id and verified by the account Trash's count rising, or every one of its copies' source reference going gone within a bounded wait (12 checks, 0.5s apart — up to 5.5s) — Mail's delete clears the source ASYNCHRONOUSLY, so "gone from the source" alone is not a signal that can be read straight after the call. A message with several stored copies gets every one of them trashed. If the call times out mid-batch, re-run the same batch or mail_undo the receipt. Needs Automation access for Mail, plus Full Disk Access to locate each message's file for the backup.

mail_duplicatesA

Where Mail is storing redundant copies of the same message — a REPORT, read-only.

Mail accumulates duplicate rows (a UI drag copies rather than moves, Gmail shows one message under a label and All Mail, migrations leave copies on two accounts). The read tools already hide them — mail_search/mail_thread/mail_overview report result per Message-ID — so this exists to show what is still physically there. Returns {redundant, mailboxes, worst, cross_account, note}: mailboxes is {mailbox_url, total, distinct_, redundant} per mailbox worst-first, worst names individual messages with the most copies ({mailbox_url, id, subject, copies}), and cross_account counts rows whose Message-ID also exists under another account. This tool CANNOT delete anything. Cleaning up is macos-apps-mcp dedupe-mail, run a terminal by the user — thousands of deletes is a job a human starts, not a tool call. Tell them that command rather than proposing per-message deletes. Fast and read-only, straight from Mail's index at rest (so it may lag Mail by a few minutes and never launches Mail). Needs Full Disk Access.

mail_undoA

Undo one recoverable Mail operation by its receipt id (from move_mail's result, or from audit). A move is undone by moving the messages back to the exact mailbox each came from — recorded per message before the original ran.

dry_run DEFAULTS TO TRUE: preview which messages would be restored, then pass dry_run=False. The undo is itself backed up, logged and verified, and returns its own receipt — so an undo can be undone. A receipt for an operation with no destination mailbox cannot be replayed; the error names the directory holding the preserved message bytes for manual re-import. Undoing a batch moved into a mailbox that already held pre-existing copies of the same Message-ID moves those copies back too — the by-ID act moves every copy of a Message-ID, and undo is an ordinary move. A receipt whose targets recorded unknown (a timeout mid-act) is replayed the same as ok. A move into a Gmail label folder or a unified mailbox name cannot be replayed; the error names the source folder and how to restore by hand. Destructive (it moves mail); needs Automation access for Mail and Full Disk Access.

update_mail_statusA

Mark Mail messages read/unread and flag/unflag them, optionally with a colour.

ids are RFC822 message-ids from a mail read, comma-separated; max 25 per call. mailbox is OPTIONAL and is the disambiguator: pass the folder value from the SAME read back VERBATIM (an opaque imap://<uuid>/<path> token) and the whole batch is addressed directly; omit it and each id is resolved through Mail's index (Full Disk Access), which also lets one call span several mailboxes. At least one of read, flagged or flag_color is required. flag_color is a name — red/orange/yellow/green/blue/purple/grey — and setting one implies flagged. dry_run defaults to FALSE: this changes two booleans, destroys nothing, and re-issuing it with the opposite value is the undo — so it does not use the backup plane that move_mail does. Each message is re-read after the write, so the per-id results report what actually persisted. Destructive (it modifies stored messages); needs Automation access for Mail.

notesA

Search Notes by title, snippet and note body. Returns {results, truncated?, plane?, coverage?}: results are pointers (id + snippet); title/snippet hits come first, body-only hits after. Body words split at spaces and punctuation only, so a word inside unspaced Chinese or Japanese text is found in a title or snippet, never in a body. Read-only. Fast path reads NoteStore.sqlite (needs Full Disk Access) and searches bodies in memory, never on disk, skipping locked notes; without it, degrades to an Automation (Notes) title search, and plane/coverage say so. Automation access is the floor. See notes_all, note_bodies.

notes_allA

List the 25 newest notes as pointers (id + "Account / Folder" + snippet), excluding Recently Deleted. Read-only. Fast path reads NoteStore.sqlite (needs Full Disk Access); degrades to Automation (Notes) enumeration without it (very large libraries can hit the osascript timeout, all-or-nothing). See note_bodies.

note_bodiesA

Hydrate plaintext bodies for up to 50 note ids (opt-in; search stays pointer-only). Returns [{"id", "body"}]; unknown ids are silently skipped. Read-only; needs Automation access for Notes. Get ids from notes / notes_all.

safari_tabsA

List open Safari tabs as pointers (url + title). Bounded to 50. Read-only; needs Automation access for Safari. See safari_open to open a URL.

music_searchA

Search the Music library + playlists as pointers. query optional name/artist/album substring (empty lists all, bounded). Read-only; needs Automation access for Music. Pointers only (id = persistent ID); no audio plays.

now_playingA

Current Music player state + track (name/artist/album/id/position/duration), or {"state": "stopped"}. Read-only; needs Automation access for Music.

photosA

Search Photos by one string, matched (case, accent and smart-quote blind) against original filename, filename, title, description, keywords, place name, person names and labels. Returns {results, truncated?}: pointers (id = the Photos uuid, summary = title · filename · date), newest first, at most 25; truncated means the 25 cap was reached and there may be more. Hidden and Recently Deleted photos never match. Pass the id to photo_info or export_photo. Reads the library at rest through osxphotos, so it needs Full Disk Access (not Automation). Read-only; never returns image bytes or thumbnails.

photo_albumsA

List Photos albums as pointers: id = the album uuid, summary = title (n photos), folder = the album's folder path (/ joined) when it sits in a folder. Returns {results, truncated?}, at most 100, ordered by folder then title; same-title albums stay separate. Needs Full Disk Access (osxphotos reads the library at rest). Read-only; the albums' photos are not listed — search with photos.

photo_infoA

Metadata for one photo by id: id, filename, title, description, kind (photo, video, live photo), edited, taken/added/modified (ISO-8601), latitude, longitude, place, persons, camera_make, camera_model, lens, focal_length, aperture, shutter_speed, iso, width, height. Photo ids come from photos; the old <UUID>/L0/001 form is accepted too. Reads the Photos library at rest through osxphotos, so it needs Full Disk Access (not Automation). Read-only; never returns image bytes, thumbnails or file paths. An unknown or Recently Deleted id raises; a Hidden one is refused (unhide it in Photos first).

export_photoA

Write one photo out as a still image file (Full Disk Access).

Writes the edited still, the image as Photos shows it; the original when there is no edit. id is a photo id from photos (the old <UUID>/L0/001 form is accepted). dest_dir must be inside the allowed root (~/Downloads by default; MACOS_APPS_FILE_ROOT moves it) and is created if needed; a path outside it is REFUSED. The filename is derived from the original name plus the first 8 characters of the id, and an existing file is never overwritten. Files over 250 MiB are refused.

Reads the library at rest, so it never launches Photos and needs no Automation. A photo whose file is only in iCloud comes back status: "absent" with a reason: nothing is downloaded. Videos are refused; a Live Photo exports its still only and says skipped: "live photo motion". Hidden and Recently Deleted ids are refused.

Returns {id, status: "written", path, bytes, edited, dest_dir}. Additive: writes a new file, never modifies the Photos library.

messages_chatsA

List Messages conversations (id + name). No content; sending isn't supported. Read-only; needs Automation access for Messages.

messages_searchA

Search Messages by text content (chat.db, read-only), newest first. Pointers: id=message guid, summary=[date] sender: snippet. Needs Full Disk Access (raises a typed error if not granted). Text-only for now; sending isn't supported.

messages_withA

Recent Messages by phone or email (chat.db, read-only), newest-first. contact a phone number or email; country an optional calling code or 2-letter region (e.g. '+32' or 'BE') to resolve a national number — default from the Mac's locale, never +1. Needs Full Disk Access (raises a typed error if not granted).

message_bodyA

Full text of one Message by id (chat.db, read-only). Decodes the attributedBody typedstream when message.text is NULL (the modern norm); returns "" for a message with no text. Needs Full Disk Access. id is a message guid from messages_search or messages_with.

shortcutsA

List/search Shortcuts by name (empty lists all). Pointers: id = the shortcut's stable UUID (survives renames), summary = name, deeplink = shortcuts://run-shortcut. Read-only; uses the Shortcuts CLI (no TCC prompt). See run_shortcut to invoke.

create_reminderA

Create a reminder. due/start ISO datetime — naive = local time, call now() first; priority 0–9; recurrence an RRULE with FREQ, INTERVAL, COUNT, UNTIL, BYDAY (ordinals such as 2TU or -1FR for monthly and yearly), BYMONTHDAY, BYMONTH, BYYEARDAY, BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are refused. This tool cannot set tags or make a subtask (no public API; read-only via reminders()). Side effect (creates); needs EventKit (Reminders) access. Target a list via list_name — a list name OR a list Pointer id (from reminder_lists). An ambiguous name is refused (with the candidate ids listed), never guessed.

create_reminder_listA

Create a reminder list in the default Reminders account. An exact-name duplicate is refused (it would make list_name= ambiguous). Returns the list pointer (id + name); use the id as list_name=. Side effect (creates); needs EventKit (Reminders) access.

update_reminderA

Update a reminder by id (full replace). due/start ISO (naive = local). recurrence: RRULE to set ('none' to stop repeating) with FREQ, INTERVAL, COUNT, UNTIL, BYDAY (ordinals such as 2TU or -1FR for monthly and yearly), BYMONTHDAY, BYMONTH, BYYEARDAY, BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are refused. REQUIRED (rule or 'none') when the target reminder repeats — omitting it is refused so a rename can't silently kill the series. This tool cannot set tags or make a subtask (no public API; read-only via reminders()). Side effect (full-replace update); needs EventKit (Reminders) access. id from reminders.

complete_reminderA

Mark a reminder complete by id. Completing a parent leaves its subtasks open (Reminders.app hides them under a completed parent); the result lists them under subtasks. Side effect (completes). Requires EventKit and Full Disk Access: the subtask check reads the Reminders store first, and when the store cannot be read the completion is refused and nothing changes. id from reminders.

delete_reminderA

Delete a reminder by id. dry_run DEFAULTS TO TRUE — previews the reminder that WOULD be deleted (pointer, no mutation); pass dry_run=false to delete. A parent with subtasks is refused unless with_subtasks=true, because deleting a parent deletes its subtasks too. A subtask indented in Reminders in the last few minutes may not be visible yet. Destructive; needs EventKit and Full Disk Access (the subtask check reads the Reminders store). id from reminders.

create_eventA

Create an event. start/end ISO datetime — naive = local time, call now() first; recurrence an RRULE with FREQ, INTERVAL, COUNT, UNTIL, BYDAY (ordinals such as 2TU or -1FR for monthly and yearly), BYMONTHDAY, BYMONTH, BYYEARDAY, BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are refused. all_day takes a DATE (2026-07-01); a timestamp with a UTC offset is rejected. A start that does not match its recurrence is accepted (RFC 5545 counts it as the first occurrence) and the result's summary says so. alarms are minutes before the start, as in the Google Calendar API. For an all-day event the offset counts from local midnight: -540 is 09:00 on the day, 900 is 09:00 the day before. On a DST-change day the alert shifts an hour, the same as Calendar's own alerts. At most 5. Side effect (creates); needs EventKit (Calendar) access. Target a calendar via calendar — a calendar name OR a calendar Pointer id (from calendars). An ambiguous name is refused (with the candidate ids listed), never guessed.

update_eventA

Update an event by id (full replace). start/end ISO — naive = local time. recurrence an RRULE with FREQ, INTERVAL, COUNT, UNTIL, BYDAY (ordinals such as 2TU or -1FR for monthly and yearly), BYMONTHDAY, BYMONTH, BYYEARDAY, BYSETPOS; BYWEEKNO, BYHOUR, BYMINUTE, BYSECOND and WKST are refused. all_day takes a DATE (2026-07-01); a timestamp with a UTC offset is rejected. A start that does not match its recurrence is accepted (RFC 5545 counts it as the first occurrence) and the result's summary says so. alarms are minutes before the start, as in the Google Calendar API. For an all-day event the offset counts from local midnight: -540 is 09:00 on the day, 900 is 09:00 the day before. On a DST-change day the alert shifts an hour, the same as Calendar's own alerts. At most 5. Omit alarms to keep the event's alarms; [] removes them all. span REQUIRED if the target is recurring: 'this-event' (only this occurrence) or 'future-events' (this + all later); ignored for single events. Side effect (full-replace update); needs EventKit (Calendar) access. id from events.

delete_eventA

Delete a calendar event by id. span REQUIRED if the target is recurring: 'this-event' (only this occurrence) or 'future-events' (this + all later); ignored for single events. dry_run DEFAULTS TO TRUE — previews the event that WOULD be deleted (pointer, no mutation); pass dry_run=false to delete. Destructive; needs EventKit (Calendar) access. id from events.

delete_noteA

Delete a note by id → Recently Deleted (recoverable ~30 days). Destructive. Pass expect_title to verify the target before deleting (content-verify first). dry_run DEFAULTS TO TRUE — previews the note that WOULD be deleted (pointer, no mutation); pass dry_run=false to delete. Needs Automation access for Notes. id from notes / notes_all.

create_noteA

Create a note and return its STABLE x-coredata id (unique in the ecosystem — immediately usable with note_bodies). title/body are plaintext (escaped, so markup is inert); folder an existing folder name (across accounts) or omit for the default folder — an unknown/ambiguous name is refused. Verified after write (#49). Side effect (creates); needs Automation access for Notes (verify read-back also uses Full Disk Access, falling back to Automation).

update_noteA

Update a note by id (full-replace title+body); the stable id is preserved and verified (#49). dry_run DEFAULTS TO TRUE — previews the CURRENT title and body size against the NEW ones (reads Notes; makes no write); pass dry_run=false to overwrite. title/body plaintext (escaped). folder must be omitted — update cannot move a note between folders and refuses a non-None folder loudly. Side effect (full-replace update); needs Automation access for Notes. id from notes / notes_all / create_note.

create_contactA

Create a contact (given/family name + organization). Side effect (creates); needs Automation access for Contacts.

run_shortcutA

Run a Shortcut by name OR its UUID id (from shortcuts — the id is unambiguous across renames/duplicate names); optional input_text piped in. Returns a pointer citing the run + a bounded output snippet. dry_run=True resolves the shortcut and reports what WOULD run without running anything. Side effect (runs arbitrary automation the user owns, which may reach beyond this machine — a shortcut can call web services); uses the Shortcuts CLI (no TCC prompt).

safari_openA

Open a URL in a new Safari tab; adds https:// if no scheme (http/https only). Side effect (opens a tab); needs Automation access for Safari. See safari_tabs.

music_controlA

Control Music playback: action in play|pause|playpause|next|previous. Additive, reversible player-state change; needs Automation access for Music. Returns the resulting now-playing state.

play_playlistA

Play a Music playlist by its persistent id (from music_search). Additive, reversible; needs Automation access for Music. Returns the resulting now-playing state.

set_volumeA

Set the Music app sound volume (0–100). Additive, reversible; needs Automation access for Music. Returns the resulting now-playing state.

set_modeA

Set Music shuffle or repeat: mode in shuffle|repeat, on=true/false (repeat on→all, off→off). Additive, reversible; needs Automation access for Music. Returns the resulting now-playing state.

Prompts

Interactive templates invoked by user choice

NameDescription

No prompts

Resources

Contextual data attached and managed by the client

NameDescription

No resources

TDQS

A3.7/5.0

Scored across 68 tools

Disambiguation3/5

The descriptions are unusually detailed and usually state exactly when to prefer one tool over another, but the Mail surface has many similarly aimed read paths (mail vs mail_search, drafts vs mail_attachments, mail_body vs mail_bodies) and narrow write tools (move_mail vs trash_mail vs delete_draft). Across 68 tools, an agent can still misselect among near-neighbors even though the descriptions try hard to disambiguate.

Naming Consistency3/5

Write tools mostly follow verb_noun (create_reminder, update_event, delete_note), but read/list tools are usually bare nouns (reminders, events, mail, notes, photos) and some use domain prefixes or noun compounds (mail_search, music_search, photo_info, now_playing). The patterns are readable but not a single predictable convention.

Tool Count2/5

68 tools is a very large surface for one server, even counting the many macOS app domains. Each domain has a plausible subset, but the aggregate set is heavy and granular enough that it burdens tool selection and maintenance; it clearly exceeds the comfortable 3–15 range.

Completeness3/5

Coverage is extensive in Mail, Reminders, Calendar, Notes, Photos, Messages, Music, and Safari, but there are notable gaps: no send_mail tool despite descriptions referencing it, Contacts has only find/create without update/delete, and several list/container lifecycle operations (reminder lists, calendars, albums) are read/create-only. Agents can often work around these by drafting or using app UI, but the surface is not full lifecycle-complete.

Maintenance

ActivityActive
ResponsivenessWithin a week