mcp-mail-macos
Allows an agent to read, search, send, move, and delete email from Gmail accounts via macOS Mail.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-mail-macossearch my mailbox for invoices from last month"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-mail-macos
An MCP server that drives macOS Mail: read, search, send, organise. It runs over stdio, launched on demand by the MCP client — there is no long-lived process.
Two mechanisms live side by side, deliberately. Actions go through AppleScript, the only interface that can make Mail do anything. Search goes through a local SQLite index built from Mail's own storage, because AppleScript needs seconds per message and cannot search an archive of tens of thousands of mails in any usable time.
Tested on macOS 27, Python 3.14, Mail 16, against Gmail, IMAP and Exchange accounts, on a mailbox of roughly 50,000 messages spanning several years.
Read this before installing. This server grants an agent the right to read, send, move and delete mail on every account Mail is configured with, and the search index needs Full Disk Access, which macOS cannot scope to a single folder. See Before you trust it with your mail.
Contents
Related MCP server: email-mcp
Before you trust it with your mail
This is a local tool for one person on their own machine. It is not a service, and it was not designed to be exposed to several users. What it asks for is broad, and worth weighing before installing.
Automation lets an agent act on every account. One grant, once, and the server can read, send, reply, move and delete across all of them. There is no per-account allowlist — adding one means filtering in two places (see Scope).
Full Disk Access is all or nothing. The index reads ~/Library/Mail, which
macOS protects; there is no setting scoped to that folder. Granting it also
grants Messages, browser history and other applications' data to whatever
application you granted it to, and for every future session until you revoke it.
Message content reaches the agent unfiltered. Anyone can send you mail, and that mail lands in an agent's context as text. That is the classic prompt injection setup, and no permission dialog stands between the two.
Reasonable precautions, in rough order of value:
Try it on a secondary account first, before pointing it at anything that matters.
Keep the confirmation guard. Every send requires
confirm=trueand returns a preview otherwise. It makes each send deliberate and shows exactly what would leave.Only grant Full Disk Access if you need indexed search, and revoke it afterwards — everything already indexed stays searchable. Set
index_max_age_minuteshigh inconfig.jsonso the server stops trying to refresh.Decide whether an agent should send at all. Preparing drafts as
.emlfiles and sending them yourself is a perfectly good mode;write_drafttouches nothing but a folder.Run the checks before real use:
python3 -m unittest discover -s tests -t .for the logic, thentest_manual.py readagainst your own Mail.
Requirements
OS | macOS, with Mail configured and its accounts loaded. AppleScript and Mail's storage layout are the whole foundation, so there is no path to another platform. |
Python | 3.11 or later — the code uses |
Version 16 (macOS 13+). The AppleScript dictionary has been stable across these releases; the internal index schema has not (see below). |
Dependencies
One, declared in requirements.txt:
mcp>=1.2.0That is the official Model Context Protocol SDK. Both generations work and the import picks whichever is installed:
SDK | Class | Import |
1.x |
|
|
2.x |
|
|
The decorator API is identical between the two, so nothing else changes.
Everything else is standard library — sqlite3 for the index and its FTS5
tables, email for parsing .emlx containers and writing .eml drafts,
subprocess for osascript, unicodedata, urllib.parse, json, tempfile.
No compiled extension, no build step.
Running the unit tests needs nothing at all beyond the standard library: they never import the SDK.
Install
git clone https://github.com/beeraw/mcp-mail-macos.git
cd mcp-mail-macos
python3 -m venv .venv
.venv/bin/pip install -r requirements.txtmacOS permissions
Two separate grants, for two different needs. The first is required. The second only concerns indexed search.
1. Automation — driving Mail
On the first call, macOS asks for permission to control Mail. The dialog appears once, and it is attributed to the application launching the server, not to Python.
If it was denied it will not ask again: restore it in System Settings →
Privacy & Security → Automation, unfold the application concerned and tick
Mail. Until then every tool returns permission_denied with that reminder.
To trigger the prompt at a quiet moment, before wiring anything up:
.venv/bin/python test_manual.py read2. Full Disk Access — building the index
The indexer reads ~/Library/Mail, which macOS protects through TCC. There is
no setting scoped to that folder: the only lever is Full Disk Access, all or
nothing, in System Settings → Privacy & Security.
The grant goes to the process's responsible application. For Claude Code
that is /Applications/Claude.app — not the nested claude-code binary, and
not Python. It is only read at launch, so the application has to be quit and
restarted.
Granted to | Consequence |
| The index refreshes itself from the MCP server. In exchange the grant covers every protected location, not just Mail, and applies to future sessions. |
Terminal only | The server can search the index but not refresh it. |
Revoking it later breaks nothing: everything already indexed stays searchable, only updates stop.
Add to Claude Code
Absolute paths, since the server can be launched from anywhere:
claude mcp add mail-macos -s user -- /path/to/mcp-mail-macos/.venv/bin/python /path/to/mcp-mail-macos/server.py-s user makes it available in every project; without the flag it stays scoped
to the current one. Check with claude mcp list. The tools appear once Claude
Code restarts.
Configuration
Nothing has to be configured: every setting falls back to something that works out of the box, and drafts and the index stay inside the repository directory.
To change any of it, copy the example and edit what you need:
cp config.example.json config.jsonconfig.json is gitignored, so local paths never end up in a commit. Any key
may be omitted. An environment variable of the form MAIL_MCP_<KEY> overrides
both the file and the default — convenient when the MCP client passes its own
configuration:
claude mcp add mail-macos -s user -e MAIL_MCP_DRAFTS_FOLDER="$HOME/Documents/Outgoing mail" -- /path/to/.venv/bin/python /path/to/server.pyKey | Default | What it does |
|
| Where |
|
| How long an unsent draft may sit on disk |
|
| How long a sent draft stays archived |
|
| How long a send stays recorded, so its leftover autosave can be recognised |
|
| The search index |
|
| Mail's storage, where the index is built from |
|
| Past this age, |
|
| Ceiling for a read call, in seconds |
|
| Ceiling for a send or move |
|
| Characters of body kept per message when indexing |
The launchd agent is the one place a path cannot come from configuration:
launchd needs absolute paths in the plist itself. Replace /ABSOLUTE/PATH/TO
in launchd/com.mcp-mail-macos.sync.plist before installing it.
The 25 tools
Search across everything
Tool | Purpose |
| Search every account, through the local index |
| The whole conversation a message belongs to |
| What the index holds and how old it is |
| Bring the index up to date |
search_all covers the whole archive in milliseconds. Subject, sender,
recipients, body and attachment names are all indexed. FTS5 syntax works —
subject: invoice, AND / OR / NOT, "exact phrase", NEAR(one two, 5).
A query that is not valid FTS5 (invoice 12/2025) is reinterpreted word by
word, which the answer reports in interpreted_as.
get_thread uses the conversation grouping Mail computes itself, carried in the
index. The whole exchange comes back, including replies filed in another mailbox
or sent from another account.
Read directly
Tool | Purpose |
| Accounts and mailboxes, with unread counts |
| Messages of one mailbox, newest first |
| Search Mail directly, bounded to recent messages |
| Full message: body, headers, attachments |
| Unread counts, per mailbox or across accounts |
These ask Mail directly, so they see the real state including what has just
arrived. search_messages is the fallback for when the index is missing or
stale; day to day search_all supersedes it.
Prepare a message for review
Tool | Purpose |
| Write a draft as an |
| Drafts waiting to be sent |
| Full content of one draft |
| Send the draft, then file it away |
| Delete a draft that will not be sent |
| Sweep forgotten drafts |
See Drafts are files for why they live outside Mail.
Send
Tool | Purpose |
| Compose and send |
| Save a draft in Mail, returns its |
| Send a draft Mail already holds |
| Reply, staying in the thread |
Confirmation is mandatory. Every tool that actually sends — send_email,
send_draft_file, send_draft and reply_to_message(send=True) — does nothing
unless confirm=true. Called without it they return confirmation_required
along with a preview block describing precisely what would go out: sender,
recipients, subject, body, and the attachments actually carried. It doubles as a
dry run.
The guard covers every send path rather than one of them: protecting only the
draft path would push a caller to recompose with send_email, which is the
behaviour worth avoiding in the first place.
to, cc and bcc accept one address, a comma-separated string, or a list.
attachments takes absolute paths to existing files, checked before Mail is
called. Without sender, Mail uses its default account — worth being explicit
when several accounts coexist.
Organise
Tool | Purpose |
| Create a mailbox, optionally nested |
| Move to another mailbox |
| Move to trash |
| Read status |
| red, orange, yellow, green, blue, purple, gray, or |
Drafts are files, not Mail drafts
write_draft writes a self-contained .eml file — attachments embedded — into
mails/. macOS renders an .eml in Mail on double-click, so it reads like a
real message. send_draft_file builds the message from that file, so what
leaves is what was reviewed, then moves the file to mails/sent/.
This is not a stylistic choice. Mail cannot send a draft it holds. Its
send command only understands an outgoing message, not a message sitting in a
mailbox; opening a draft turns it into one, but only after an unpredictable
delay that exceeded a minute in testing; moving it to the Outbox does nothing at
all. Anything drafted inside Mail therefore has to be re-posted and the original
deleted — and on a Gmail account that delete is undone by the server unless it
is issued once the send has settled.
Keeping drafts out of Mail removes the problem rather than working around it. Sending becomes a single instant operation with nothing to clean up afterwards.
send_draft remains for drafts written by hand inside Mail. It re-posts and
deletes them, waiting for the sync to settle, which takes about twenty seconds.
Retention. An unsent draft is removed after 7 days, an archived one after
30. The sweep runs on every write, every listing, and from sync_index, so a
forgotten draft does not sit on disk indefinitely. Files live in
mcp-mail-macos/mails/ unless folder says otherwise, and are gitignored:
they hold real message content.
The search index
Why
Mail answers message by message. On a mailbox of around 20,000 messages, reading
metadata costs about 0.65 s per message and reading a body about 1.7 s. Mail's own search
(whose subject contains …) takes about 21 s over 2,500 messages, and searching
bodies exceeds 120 s — to the point of leaving Mail unresponsive to every
subsequent call for minutes.
Searching an archive of tens of thousands of messages that way would take hours. The index sidesteps it by reading Mail's storage directly.
What feeds it
Two sources, neither sufficient alone:
MailData/Envelope Index, Mail's internal SQLite database, for metadata, mailbox membership, and read and flag status. It is copied — together with its write-ahead log — then opened read-only.The
.emlxfiles, for body text and the RFCMessage-IDheader.
Mail's index holds no full text; the files do not say which mailboxes a message belongs to.
Indexed, not stored
Bodies go into an FTS5 table declared content='': searchable, never kept. The
database stores only what is needed to display a result and act on it — subject,
sender, date, Message-ID, locations. Reading a message goes back through
get_message. For roughly 50,000 messages the index weighs about 80 MB.
messages (id, account, rfc_id, subject, sender, date_received, size, conversation_id)
locations (message, account, mailbox, read, flagged)
messages_fts(subject, sender, recipients, attachments, body) -- FTS5, content=''Splitting message from locations absorbs Gmail's duplication: a message exists once on disk, in All Mail, and labels are only views. A mailbox of some 50,000 distinct messages yields around 135,000 locations — which is exactly the figure AppleScript reports when its mailboxes are summed.
The durable key is the RFC Message-ID, not Mail's internal id, which changes
whenever a message moves.
Build and update
python3 mail_index.py --check # verify assumptions, build nothing
python3 mail_index.py --build # full backfill
python3 mail_index.py --sync # incremental
python3 mail_index.py --search "invoice acme"--check validates seven points, including that message ids map to files and —
most importantly — that membership rebuilt from both sources matches Mail's own
per-mailbox counts. --build refuses to start if any of them fails:
Envelope Index is undocumented and changes between macOS releases, so a clean
refusal beats a silently wrong index.
--sync diffs the set of messages Mail lists against the set the index holds.
Deletion is not a special case, and a move reads as a change of location at
constant Message-ID. A pass with nothing to do costs about two seconds.
Freshness
search_all checks the index's age and runs the sync itself past
max_age_minutes (10 by default). The answer carries index_age_minutes and
synced, so the caller knows what was searched. If disk access was revoked in
the meantime the search still succeeds against the existing index and says so in
sync_note rather than failing — a stale result beats an error. A lock prevents
two concurrent syncs.
Background sync
launchd/com.mcp-mail-macos.sync.plist runs --sync every ten minutes,
independently of any client:
cp launchd/com.mcp-mail-macos.sync.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.mcp-mail-macos.sync.plistA warning before installing it: the agent reads ~/Library/Mail, so Full Disk
Access has to be granted to the program it runs, /usr/bin/python3. That hands
the grant to every Python script on the machine — wider than an app-scoped
one. A venv interpreter is no better: its path carries a version number and the
grant breaks on the first upgrade.
The agent is only worth it if the index must stay current with no client
running. Otherwise search_all's own freshness check is enough, and the grant
stays scoped to a single application.
Message identifiers
Every message carries an opaque message_id encoding the account, the mailbox
path and Mail's internal id. It is stable between calls and survives a Mail
restart — it is not a position in a list.
Two caveats. The internal id is only unique within a mailbox, hence the account
and path travelling with it. And a move creates a new one: move_message
returns the new message_id when it can find the moved copy through its
Message-ID header, and says so when it cannot.
A reference from search_all or get_thread has the same shape and works
directly in get_message, reply_to_message or move_message. It points at
the smallest mailbox holding the message, because Mail resolves an id by
walking the mailbox it is given: aiming at a folder of a few thousand messages
rather than one holding tens of thousands changes the response time by an order
of magnitude.
Response format
Every function returns a dictionary. On failure:
{
"ok": false,
"error_code": "mailbox_not_found",
"error": "mailbox not found: Drafts (account Work)",
"hint": "Call list_mailboxes to see the exact mailbox paths."
}Errors are data, not protocol exceptions, so a caller can correct itself from the code and the hint.
Code | Cause |
| macOS refuses control of Mail, or access to its storage |
| Mail did not answer in time |
| Mail is closed and could not be started |
| Target not found |
| Malformed identifier |
| Attachment missing from disk |
| A send was requested without |
|
|
| Attachments could not be recovered; nothing was sent |
|
|
| Index absent, incomplete, or not refreshable |
Known limitations
All of these come from Mail, not from this server. The figures were measured on an M4 Pro MacBook Pro against real accounts.
Speed
Operation | ~2,500-message mailbox | ~20,000-message mailbox |
Metadata for 20 messages | ~1 s | ~13 s |
Metadata for 200 messages | ~13 s | — |
One message body | ~1.7 s | ~1.7 s |
A mailbox's | instant | instant |
This dictates the defaults: include_preview and include_totals are off,
search_messages bounds itself to a window of recent messages and reports what
it actually scanned, and general search goes through the index.
What Mail cannot do at all
Send a draft it holds.
sendonly understands an outgoing message (-1708). Opening the draft produces one only after an unpredictable delay, sometimes over a minute. Moving it to the Outbox does nothing. Hence.emlfiles, and hencesend_draftre-posting and deleting. The only faithful alternative reported by the community is GUI scripting (Cmd+Shift+Dthrough System Events), which needs Accessibility permission and breaks with any interface change — deliberately not taken here.Delete a mailbox.
delete mailboxfails with -10000 whatever the syntax. A mailbox created bycreate_mailboxhas to be removed by hand.Export an attachment. Mail refuses to write the file anywhere (-10004), so
send_draftreads attachments out of the stored.emlx, which needs Full Disk Access. Without it, it refuses to send rather than send a message missing its files.Set headers on an outgoing message. There is no way to build an
In-Reply-Toby hand, which is whyreply_to_messagegoes through Mail's ownreplycommand — briefly opening a compose window. That is the only way to get a reply properly attached to its thread.Create a mailbox with an
accountproperty. It has to happen inside atellblock targeting the account, or -10000.Send HTML reliably. Bodies go out as plain text. A hand-written rich-text draft loses its formatting through
send_draft.
Behaviours worth knowing
Mail autosaves what it composes. With no window to close, that autosave is sometimes left behind as a draft once the message has gone — intermittently, depending on whether the timer fired before the send. It often appears several seconds after the send, too late to be cleaned up inline. The server records what it sends in
mails/.sent-ledger.json, and the sweep removes drafts matching a recent send on both subject and recipient. A hand-written draft matches nothing and is never touched.A Gmail account restores a deleted draft. Deleting during the sync that follows a send reports success, then the server pushes the draft back a few seconds later. The same delete issued once the send has settled sticks permanently. That is why
send_draftwaits before deleting.Outgoing messages accumulate in Mail's internal list, even after being sent, and cannot be closed through AppleScript. They are invisible, and cleared by restarting Mail. This matters because identifying a compose window by position rather than by id will eventually pick the wrong one — and send it.
Mail counts a signature image among the attachments. It is not re-sent as one; the answer reports it under
kept_inline.Gmail labels are mailboxes, and one message appears in several.
INBOXcan resolve to All Mail: a message'smailboxfield reports where Mail sees it, which is not always what was queried.every mailbox of accountreturns leaf names, but lookup by slash-separated path works. The server rebuilds full paths by walking thecontainerproperty.An attachment's name is sometimes inconsistent between calls; its size is reliable.
A disabled account disappears from Mail's list without an error.
AppleScript calls are wrapped in an explicit
with timeout; without it any call over 60 s fails, which a large mailbox reaches easily.Numbers and dates coerced to text follow the machine's locale — a date becomes
1,785863539E+9. The server assembles ISO 8601 dates digit by digit to avoid it.
Message content reaches the client unfiltered
Everything these tools return — bodies, subjects, sender names, attachment names — is whatever arrived in the mailbox, passed through untouched. A message can therefore contain text that reads like an instruction, and an agent consuming this server will see it alongside its own. Treat mail content as data, never as direction, and be wary of a tool call whose arguments were lifted verbatim from a message. This is not specific to this server, but it is worth stating: reading mail on an agent's behalf is exactly the situation prompt injection targets.
read_draft_file takes a path and parses whatever is there as an email, so any
readable file on the machine can be turned into a body and handed back. That is
deliberate — folder would be pointless otherwise, and attachments already
require arbitrary paths — but it means the server is as trusted as the client
driving it. It is meant to run locally, for one user.
Scope
The server exposes every account Mail knows about, for reading and writing
alike. There is no account allowlist. Adding one means filtering in two places —
MessageReference.decode and resolveMailbox on the AppleScript side, plus a
WHERE account IN (...) on the index — because the AppleScript tools reach Mail
directly and would otherwise still see everything.
The index reflects Mail's local store. What an account has not synced does not exist for Mail, and therefore not for search either.
Testing
Unit tests cover everything that does not need Mail: identifier encoding,
address parsing, error classification, AppleScript assembly, .eml round-trips,
retention and the ledger. They run anywhere, in under a second:
python3 -m unittest discover -s tests -t .Manual checks exercise the live path against a real Mail install:
.venv/bin/python test_manual.py read # read-only
.venv/bin/python test_manual.py read --account Work --mailbox INBOX
.venv/bin/python test_manual.py write --to you@example.com # draft + mailbox
.venv/bin/python test_manual.py write --to you@example.com --send # really sendsread changes nothing: eight checks, two of which verify that errors surface
cleanly. write creates a draft and a test mailbox in Mail; the mailbox has to
be deleted by hand, since Mail cannot do it through AppleScript.
Project layout
mcp-mail-macos/
├── server.py # MCP entry point, the 25 tool definitions
├── mail_tools.py # driving Mail through AppleScript
├── mail_files.py # .eml drafts, retention, leftover sweep
├── mail_search.py # querying the index
├── mail_index.py # building and updating the index
├── test_manual.py # manual checks against a real Mail install
├── tests/ # unit tests, no Mail required
├── applescript/ # one script per operation, plus shared handlers
│ ├── _common.applescript
│ └── …
├── launchd/ # optional periodic sync agent
├── requirements.txt
└── README.mdAppleScript files are assembled at run time: _common.applescript is prepended
to each script, and a with timeout wrapper is added around the run handler.
Parameters travel through argv rather than string interpolation, which rules
out injection, and -- protects values starting with a dash. Results are
serialised with ASCII separators 31 and 30, which never appear in real mail and
are stripped from values before joining — hence no escaping when parsing.
License
MIT. See LICENSE.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityAmaintenanceMCP server that gives Claude and other MCP hosts full access to Mail.app on macOS — search, read, send, reply, flag, move, and more across all accounts configured in Mail.app.Last updated2460MIT
- AlicenseAqualityBmaintenanceLocal MCP server for multi-account IMAP/SMTP email (iCloud + Gmail via app-specific passwords). Never marks mail read. Cross-folder search, idempotent sends, TLS verified.Last updated8MIT
- Alicense-qualityDmaintenanceLocal MCP server for macOS Mail reads plus visible unsent compose, reply, and forward drafts, and constrained single-message moves.Last updatedMIT
- AlicenseBqualityBmaintenanceLocal MCP server for macOS native apps: Mail, Calendar, Reminders, Notes, Messages, and Contacts. Enables reading and organizing your Mac life through a single stdio process using AppleScript/JXA.Last updated4021MIT
Related MCP Connectors
Read, search, send, organize, draft and schedule email across your inboxes from any MCP client.
MCP connector for iMessage & Contacts via a local Mac agent + Vercel relay
Remote MCP server for full read/write access to a Zotero library
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/beeraw/mcp-mail-macos'
If you have feedback or need assistance with the MCP directory API, please join our Discord server