iMessage Max
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
iMessage Max
For a Mac app to set it up and manage it, use the native menu bar companion. It checks setup, manages the local service, and copies the connection details your assistant needs.
iMessage Max is an MCP (Model Context Protocol) server for iMessage. AI agents use it to read, search, and send your messages. It resolves phone numbers to contact names.
It is written in Swift and ships as one Mac app with no runtime dependencies.
Distribution
There is one Swift implementation, in swift/. It ships as:
iMessage Max.appin a notarized DMG on GitHub releases (Apple silicon, macOS 15 or later)a Homebrew cask (
cyberpapiii/tap/imessage-max) that installs the same appa Claude Desktop extension (
iMessage-Max-<version>.mcpb) on GitHub releasessource builds (swift/README.md)
Codex plugin metadata (
.codex-plugin/)Claude Desktop MCPB metadata (
mcpb/)
Related MCP server: jons-mcp-imessage
Features
14 tools, each shaped around a question people ask rather than around a database table
Phone numbers resolve to names through macOS Contacts
Three image variants: vision (1568px), thumb (400px), and full (original), so an agent can fetch a photo below full size
A gap of 4 hours or more starts a new message session
Attachment listings say whether each file is on disk or offloaded to iCloud
Queries use raw SQLite3, and image resizing runs on the GPU through Core Image
The server only reads chat.db. Sending needs Automation permission for Messages.app. You never have to open Messages: when the server needs it, it starts Messages hidden, with no window and no change of focus
Why this exists
Most iMessage tools expose raw database structures, so one user request takes 3 to 5 tool calls. iMessage Max has tools that match what the user asks for:
"What did Contact A and I talk about yesterday?"
→ find_chat(participants=["Contact A"]) + get_messages(since="yesterday")
"Show me the exact details for this thread before I reply"
→ get_chat_details(chat_id="chat123")
"Show me photos from the group chat"
→ list_attachments(chat_id="chat123", type="image")
"Find where we discussed the launch timeline"
→ search(query="launch timeline")
"Text Sam that I'm running late" (never messaged before)
→ send(to="Sam", text="Running late")Common agent workflows
The tools work best when an agent chains them into short workflows instead of making isolated one-off calls.
Agents should treat chat_id values like chat123 as internal handles for tool calls and exact sends. When explaining results to a person, use the returned chat name, group name, or participant-derived label instead of saying "Chat 123."
Find the right conversation, then read it
find_chat(participants=["Contact A"])
get_chat_details(chat_id="chat123")
get_messages(chat_id="chat123", since="yesterday", limit=50)Use this when the person matters more than the exact thread id.
Search first, then zoom in
search(query="launch timeline", limit=10)
get_context(message_id="msg_456", before=5, after=10)Use this when you know the topic but not where it was discussed.
Check what needs attention
get_unread()
get_active_conversations(hours=24, min_exchanges=2)Use this to find unread threads and active conversations after a broad sweep of the chat list.
Work with attachments safely
list_attachments(chat_id="chat123", type="image", since="30d")
get_attachment(attachment_id="att123", variant="vision")Use list_attachments to find the message where files were shared. It returns exact attachment ids and says whether each file is on disk, so you know before you fetch.
Message someone new
find_contact(query="Sam")
send(to="+15550000009", text="Hi, it's Rob from the climbing gym")Use this when a name may match more than one person, or when the person has a number and an email. find_contact reads Contacts, so it finds people you have never messaged. send(to="Sam") alone works when only one number or email in Contacts matches.
Send with exact targeting when it matters
find_chat(participants=["Contact A", "Contact B"])
send(chat_id="chat456", text="Please use the latest draft")For sensitive sends, prefer to resolve the exact chat first and then send with its chat_id. That way the message goes to the intended thread.
Installation
Open the DMG from the latest release and drag iMessage Max to Applications.
Or run brew install --cask cyberpapiii/tap/imessage-max.
Upgrading from 1.x
Version 1.x installed a Homebrew formula and a launch agent you set up by hand. The formula is retired. Run brew uninstall imessage-max first, because the cask links the same imessage-max command. Then install the app and open it. It offers Replace the older helper, which stops the old agent and removes its files. The new helper listens on port 47821 and needs a token, so reconnect each assistant from the app's Connections page.
Headless Macs, MDM and uninstall
docs/headless.md: run iMessage Max on a Mac with no display, such as a Mac mini you reach over SSH. The Mac still needs a desktop login that stays logged in, system sleep off, and a one-time setup over Screen Sharing. The guide also covers updates, health checks over SSH and troubleshooting.
docs/fleet.md: grant Full Disk Access, Contacts and Automation, and allow the helper, with an MDM profile keyed to
com.cyberpapiii.imessage-max.menubar.docs/uninstall.md: remove the app, the helper, its files and its privacy grants, for DMG and cask installs.
Protocol support
iMessage Max is a dual-era MCP server. Both transports, stdio and HTTP, serve both eras at the same time. The server picks the era for each request:
Era | Revisions | Lifecycle | Selected by |
Modern |
| Stateless, per-request |
|
Legacy |
|
|
|
Modern clients probe with server/discover and send the required
MCP-Protocol-Version, Mcp-Method, and (for tools/call) Mcp-Name
headers over HTTP. Legacy clients keep working without changes. No client
has to migrate, and legacy support stays as long as real clients depend
on it.
Stdio clients do not run the server themselves. imessage-max launched
with no transport flag is a bridge: it copies stdin and stdout to the running
helper (imessage-max --http) over the Unix socket
~/Library/Application Support/iMessage Max/mcp.sock (mode 0600), so only the
helper needs Full Disk Access, Contacts and Automation. Each bridge connection
is its own stdio session, serving both eras. A helper restart does not end
the session: the bridge reconnects within 15 s, replays the client's
initialize, and answers only a request that was running during the restart
with an error that says to retry it. When the helper is not running after
15 s, the bridge answers the first request with "Open iMessage Max to start
it." and exits. When no one is logged in to the Mac's desktop, it says that a
desktop login is needed instead, since the helper cannot start without one. imessage-max --direct serves stdio in process, as before, for
development. If the home folder path is too long for a socket address, both
sides use imessage-max.sock in the per-user temporary folder instead.
The server implements only tools. By design, it has no prompts, resources,
completion, subscriptions, tasks, or MRTR flows. The official
conformance suite runs against both eras with the documented baseline in
docs/conformance-baseline.yml.
Client icon metadata
iMessage Max ships icons for the MCP protocol itself and for each client package format that carries its own metadata:
MCP
2025-11-25initialize responses include PNGserverInfo.icons.Each tool advertises a compact PNG tool icon.
Codex plugin metadata lives in
.codex-plugin/plugin.jsonand usesassets/codex/icon.pngplusassets/codex/logo.png.Claude Desktop / MCPB metadata lives in
mcpb/manifest.jsonand uses PNG assets undermcpb/assets/.
The source PNGs are committed under assets/icons/ in these sizes: 16x16,
32x32, 64x64, 128x128, 256x256, and 512x512.
Setup
1. Open iMessage Max and grant its permissions
Open iMessage Max from Applications. It adds a speech bubble to the menu bar and opens its Settings window on Permissions, which lists four permissions with the status of each. The first one that is missing shows a button that grants it:
Background activity, so the helper keeps serving your assistants after you close the window or quit the app, and starts again at login
Full Disk Access, to read
~/Library/Messages/chat.dbContacts, to turn phone numbers into names
Automation for Messages, to send
System Settings lists each of them as "iMessage Max".
For contact names, choose Allow Contacts under Contacts. The app asks macOS for Contacts on the helper's behalf, because macOS charges the helper's grants to the app, and then restarts the helper. A bare imessage-max from a source build cannot hold a Contacts grant of its own. Under launchd or an MCP client it still reads messages, and shows phone numbers in place of names. --contacts-policy request|skip, or IMESSAGE_MAX_CONTACTS_POLICY, overrides its terminal detection.
2. Connect your assistant
Once every permission is on, the Permissions page offers to connect the assistant you use. The app's Connections page lists the assistants it finds on this Mac (Claude, Claude Code, Codex, Plug and Cursor) and connects each with one button. To set up another client by hand, use one of three ways to reach the helper:
HTTP, for Plug, Codex and Claude Code:
http://127.0.0.1:47821, with the headerAuthorization: Bearer <token>. The token is in~/Library/Application Support/iMessage Max/token.stdio, for any client that runs a command:
imessage-max. The cask links that command. With the DMG, use/Applications/iMessage Max.app/Contents/MacOS/imessage-max. It is a bridge to the helper, so it needs no permissions of its own.Claude Desktop: open
iMessage-Max-<version>.mcpbfrom the release.
{
"mcpServers": {
"imessage": {
"command": "imessage-max"
}
}
}3. Reconnect your assistant
After you save the config, reconnect or restart your assistant. The tools then appear in it. Run diagnose to check the connection.
Tools
find_chat
Find chats by participants, name, or recent content.
By default, find_chat hides chats that Messages.app has filtered into Unknown Senders or Junk. The response carries filtered_hidden, the number of chats the filter removed from this view. Pass include_filtered=True to see them.
find_chat(participants=["Contact A"]) # Find a direct chat
find_chat(participants=["Contact A", "Contact B"]) # Find a group with both
find_chat(name="Project Group") # Find by chat name
find_chat(contains_recent="latest draft") # Find by recent content
find_chat(name="Project Group", include_filtered=True) # Also search junk / unknown-sender chatsfind_contact
Look up a person in Contacts by name, phone number, or email, including people you have never messaged.
find_contact(query="Sam") # Every contact whose name has a word starting with "sam"
find_contact(query="(555) 000-0001") # The contact that owns this number
find_contact(query="Sam", limit=10) # Default 5, max 25{
"contacts": [
{"name": "Sam Lee", "handles": [
{"handle": "+15550000001", "chat_id": "chat1", "last_contact": "3d ago"},
{"handle": "sam@example.com", "last_contact": "Sep 30"}
]},
{"name": "Samira Khan", "handles": [{"handle": "+15550000009"}]}
],
"more": false
}Each contact lists every number and email it has. A handle with a 1:1 chat carries that chat_id, the chat send(to=handle) would use. last_contact is the newest message from or to the handle in any chat. A handle with neither has never been in Messages, and send(to=handle) starts the conversation. Contacts messaged most recently come first. Contacts that share a display name are listed as one. The lookup reads the Contacts copy the server already holds in memory, so a large address book does not slow it down. Without Contacts access it fails with contacts_unavailable.
get_chat_details
Inspect a known thread without opening the full conversation.
get_chat_details(chat_id="chat123") # Participants, handles, state, last message
get_chat_details(chat_id="chat123", include_shared_summary=false) # Skip recent shared summaryget_messages
Retrieve messages, filtered by time, sender, or content type. For media, it returns metadata. A lookup by explicit chat_id is never filtered.
Time bounds (since, before) on every tool take ISO 8601 (2026-01-15T10:30:00Z), relative (30min, 24h, 7d, 2w, 3mo) or natural (yesterday, last tuesday, 2 weeks ago) forms. A bare m is refused with an error, because previews write minutes as 5m ago: write min for minutes or mo for months. A bound that does not parse is an error, not a filter left out.
get_messages(chat_id="chat123", limit=50) # Recent messages
get_messages(chat_id="chat123", since="24h") # Last 24 hours
get_messages(chat_id="chat123", from_person="Contact A") # From specific person
get_messages(chat_id="chat123", has="links") # Messages that contain a URLhas filters by content type: links, attachments, or images. links also matches link messages that Messages stores as URL preview balloons, which is the common case on macOS 26.
Group system messages (renames, members added or removed, someone
leaving) come back with text: null and an event object:
{"type": "rename", "title": "Trip"}, {"type": "participant_added", "participant": "Alice"},
{"type": "participant_removed", ...}, {"type": "left"}, or
{"type": "other", "item_type": N} for event kinds the server does not
name. Chat previews describe the same events in words
("renamed the group to Trip").
For reactions, replies, edits, and unsends, each message may include reactions
(["❤️ alice"] for standard tapbacks; custom emoji show the emoji
itself; sticker reactions use the token 🩵 sticker), reply_to
(msg_<rowid> of the originator), reply_count, edited: true, and
unsent: true. A message the sender unsent keeps its place in the
thread with unsent: true and no text, so it no longer reads as a
blank message. It never also carries edited. When the sender unsent
one part of a message, such as a photo, and the text stayed, the
message shows its text with edited: true.
Removed tapbacks are omitted. All five fields are optional and omitted
when empty. search, get_context and get_messages_since carry the
same five fields, and get_unread with format="messages" carries
unsent. The server only reads these. It cannot send them, so
diagnose still reports tapbacks and edit_unsend as unsupported.
get_messages_since
New messages across all chats after a ROWID cursor, in arrival order. Pass the returned next_rowid back as since_rowid to page or poll. next_rowid may be larger than the last returned message's rowid because consumed rows (reactions, filtered chats, orphans) advance it. Omit since_rowid to get only the current cursor. A cursor is valid only against this Mac's chat.db.
get_messages_since() # Current cursor only
get_messages_since(since_rowid=234000) # Messages after that row
get_messages_since(since_rowid=234000, limit=50)
get_messages_since(since_rowid=234000, chat_id="chat123")
get_messages_since(since_rowid=234000, include_filtered=True)
get_messages_since(since_rowid=234000, max_age=86400) # Skip anything dated over a day agoParameter | Type | Default | Meaning |
| integer | omitted | Exclusive ROWID cursor. Omit or pass |
| string | omitted | Restrict to one chat ( |
| integer | 100 | Maximum messages to return (1–500) |
| boolean | false | Include junk / unknown-sender chats |
| boolean | true | Attach reaction strings to returned messages |
| integer | omitted | Skip messages dated more than this many seconds before now. Skipped rows still advance |
Example response:
{
"since_rowid": 234000,
"messages": [
{
"id": "msg_234001",
"rowid": 234001,
"chat": {"id": "chat12", "name": "Alice Smith"},
"from": "Alice Smith",
"text": "on my way",
"ts": "2026-09-02T15:00:00Z"
},
{
"id": "msg_234010",
"rowid": 234010,
"chat": {"id": "chat40", "name": "Weekend"},
"from": "me",
"text": "see you there",
"ts": "2026-09-02T15:01:00Z"
}
],
"next_rowid": 234050,
"has_more": false,
"current_rowid": 234050,
"stalled": false,
"filtered_hidden": 1
}To poll, call once without since_rowid and store next_rowid. Then call with it on whatever schedule you choose. When stalled is true, wait about one second and retry with the same cursor. Never compare next_rowid to message ids.
Messages in iCloud can sync old history into chat.db long after it was sent, for example on a newly set-up Mac. Those rows get new ROWIDs, so they come after your cursor. A message with late: true is such history: its date is more than an hour older than the newest of the rows already in chat.db before it. Do not treat it as new mail. Messages that arrive after the Mac wakes from sleep are dated after the rows before them, so they are not marked late. On a legacy HTTP session the notifications/imessage/new_messages notification carries late: true when every new row is late.
wait_for_messages
Wait for a reply in one call. It blocks until a new message that matches the filters arrives, then returns it, or returns status: "timeout" when none arrives in time. It answers "tell me when Sam replies" without a polling loop, on every transport and protocol era. MCP clients such as Claude Code and Codex do not act on the server's notifications/imessage/new_messages, so before this tool an agent had to poll get_messages_since.
wait_for_messages(from_person="Sam") # Sam's next message, in any chat
wait_for_messages(chat_id="chat123", since_rowid=234000) # Anything new in one chat after the cursor
wait_for_messages(since_rowid=234000, include_from_me=True) # Count my own messages tooParameter | Type | Default | Meaning |
| integer | omitted | Exclusive ROWID cursor, such as |
| string | omitted | Only messages in this chat ( |
| string | omitted | Only messages from this person: a name, phone number, email, or |
| boolean | false | Also count messages I send. |
| boolean | false | Include junk / unknown-sender chats |
| integer | 30 | Seconds to wait (1–50) |
| integer | 100 | Maximum messages to return (1–500) |
The response is the get_messages_since response plus status:
{
"status": "messages",
"since_rowid": 101,
"messages": [
{"id": "msg_102", "rowid": 102, "chat": {"id": "chat2", "name": "Weekend"},
"from": "Bob Brown", "text": "on my way", "ts": "2026-11-09T21:09:57Z"}
],
"next_rowid": 102,
"has_more": false,
"current_rowid": 102,
"stalled": false,
"filtered_hidden": 0
}status is messages when at least one message matched, timeout when none did, or stopping when the helper began to stop or restart for an update while the call waited. In every case, pass next_rowid back as since_rowid to keep waiting: rows the filters skipped are behind it, and rows that arrive between calls are ahead of it, so nothing is missed. The cursor is the same one get_messages_since uses.
Reactions do not end a wait, and neither does late-synced history (see late above). The wait ends at once when the client cancels the call (notifications/cancelled), and when the helper drains on SIGTERM, SIGUSR1, or an installed update, so a wait never delays a restart. At most 32 calls wait at a time; past that a call fails with too_many_waits.
The 50 s ceiling stays under Codex's default 60 s tool timeout (tool_timeout_sec) and the helper's 60 s idle connection timeout. Claude Code allows about 28 hours by default. Through plug, keep this server's call_timeout_secs above 50. Plug's per-server max_concurrent caps the calls it sends to this server at once, so at 1 a wait holds back every other iMessage call through plug until it returns. Raise it if agents wait while doing other work.
get_attachment
Retrieve an attachment's content by attachment ID. The first block is always JSON metadata (id, type, name, chat, available, plus fields for the type). The blocks after it carry the attachment.
get_attachment(attachment_id="att123") # Default: vision (1568px)
get_attachment(attachment_id="att123", variant="thumb") # Quick preview (400px)
get_attachment(attachment_id="att123", variant="full") # Original resolution
get_attachment(attachment_id="att456", offset=10000) # A PDF's text from character 10,000Variant | Resolution | Use Case | Token Cost |
| 1568px | AI analysis, OCR | ~1,600 tokens |
| 400px | Quick preview | ~200 tokens |
| Original | Maximum detail | Varies |
PDFs and documents come back as their text, one text block of at most max_chars characters (default 10,000, max 50,000). Readable files are PDFs, plain text of any kind (.txt, .csv, .json, source code, contact cards, HTML as its source), RTF, Word (.doc, .docx) and OpenDocument text (.odt). The metadata gives chars (the whole text's length), pages for a PDF, and next_offset while text remains. Pass next_offset as offset to read on. A PDF's text marks each page with [page N]. A PDF with no text layer, such as a scan, comes back as images of its first 3 pages at the requested variant, with chars: 0 and rendered_pages. Spreadsheets and presentations still return unsupported_type.
{"id":"att5","type":"pdf","name":"lease.pdf","chat":{"id":"chat3","name":"Alice Smith"},"available":true,"pages":12,"chars":48210,"next_offset":9987}A video comes back as up to 4 still frames, taken from the middle of four equal spans so none is the black first frame. Each frame is half the variant's size on its long side (vision 784px, thumb 200px, full 1568px), so the four frames together cost about as much as one image of that variant. A clip shorter than 4 seconds gets one frame per second. The metadata gives duration_s and frame_times_s, the time of each frame in seconds. The video's sound is not transcribed.
{"id":"att5","type":"video","name":"IMG_0412.MOV","chat":{"id":"chat3","name":"Alice Smith"},"available":true,"duration_s":8,"frame_times_s":[1,3,5,7]}A voice message or other audio comes back as its transcript, one text block, with duration_s and transcript_source in the metadata. For a voice message, the transcript is the one Messages shows under it, which Messages keeps in the message's attributedBody (transcript_source: "messages"). Other audio, and voice messages that Messages never transcribed, are transcribed on this Mac with Apple's SpeechTranscriber in the Mac's language (transcript_source: "on_device", plus locale). That needs macOS 26 or later and the on-device speech model for the language, and audio over 10 minutes is not transcribed. Neither way needs a permission beyond Full Disk Access. When there is no transcript, transcript_unavailable says why and no text block follows.
{"id":"att4","type":"audio","name":"Audio Message.caf","chat":{"id":"chat3","name":"Alice Smith"},"available":true,"duration_s":3.2,"transcript_source":"messages"}list_chats
Browse recent chats with previews.
By default, list_chats hides chats that Messages.app has filtered into Unknown Senders or Junk. The response carries filtered_hidden, the number of chats the filter removed. Pass include_filtered=True to see them.
list_chats(limit=20) # Recent chats
list_chats(is_group=True) # Only group chats
list_chats(since="7d") # Active in last week
list_chats(include_filtered=True) # Also show junk / unknown-sender chatssearch
Full-text search across messages.
By default, search hides junk and unknown-sender chats. The response carries filtered_hidden. Pass include_filtered=True to search them too.
search(query="draft") # Search all messages
search(query="budget", from_person="Contact A") # From specific person
search(query="launch", is_group=True) # Only in group chats
search(query="draft", include_filtered=True) # Also search junk / unknown-sender chatsget_context
Get messages surrounding a specific message.
get_context(message_id="msg_123", before=5, after=10)get_active_conversations
Find chats with recent back-and-forth activity.
get_active_conversations(hours=24)
get_active_conversations(is_group=True, min_exchanges=3)list_attachments
Browse shared items grouped by message. Each row includes exact attachment ids for follow-up fetches.
list_attachments(type="image", since="7d")
list_attachments(chat_id="chat123", type="any")list_attachments skips attachments that Messages hides with hide_attachment, such as link-preview payloads. get_attachment still returns them by id.
get_unread
Get unread threads or unread messages. By default, it returns a summary by chat.
By default, get_unread hides junk and unknown-sender chats. The response carries filtered_hidden. Pass include_filtered=True to include them.
get_unread() # Summary by chat for last 7 days
get_unread(since="24h") # Summary by chat for last 24 hours
get_unread(format="messages") # Row-level unread messages
get_unread(include_filtered=True) # Also show junk / unknown-sender chatssend
Send a message or file attachment (requires Automation permission for Messages.app).
send(to="Contact A", text="Checking in")
send(chat_id="chat123", text="Please use the latest draft")
send(chat_id="chat123", file_paths=["/path/save-the-date.jpg"])
send(to="Contact A", file_paths=["/path/reference.png"], text="Sharing the file here")Rules:
Exactly one of
toorchat_idAt least one of
textorfile_pathsIf you pass both,
sendsends the files first and the text lasttotakes a contact name, a phone number, or an email. A name must match exactly one number or email in Contacts. A name that matches more, even two handles of one contact, returnsambiguouswith the candidates and sends nothing.find_contactlists themA phone number or email that Messages has never seen starts a new conversation, and so does the full name of a contact whose one handle is such a handle. A partial name ("Sam" for Samira Khan) whose one match Messages has never seen returns
ambiguouswith that candidate, so the agent can check with the user first. The responsemessagethen says "Messages had no conversation with before this send." A number written without a country code is read in the Mac's region, and one that cannot be read that way is refused
Attachment paths must be absolute or start with ~/. send checks the
path one component at a time and refuses it if any component is a symbolic
link. /tmp, /var, and /etc are allowed and read as /private/....
send opens the file without following links, and the file must be a
regular file. send copies it from that open handle into a private 0700
directory under ~/Pictures/imessage-max-staging/. Messages only ever sees
the copy. If ~/Pictures itself is a symlink on your Mac, send with a file
refuses to stage it until ~/Pictures points at a real directory.
diagnose
Troubleshoot configuration and permission issues.
diagnose() # Returns: database status, contacts count, permissions, capabilitiescapabilities.verified_send is supported when chat.db is readable and Messages Automation is OK, degraded when the database is readable but Automation is not OK, and permission-gated when the database is not readable.
database.features lists which optional chat.db columns exist on this Mac, keyed table.column (for example message.date_edited). When one is false, the tools that read it degrade instead of failing: no reply_to or reply_count without message.thread_originator_guid, no edited without message.date_edited, no unsent when neither message.message_summary_info nor message.date_retracted exists (an unsent message then shows as an edit with no text), no custom-emoji reaction text without message.associated_message_emoji.
contacts.status is one of authorized, limited, denied, restricted, not_determined, not_requested_headless, or <status>_load_failed. A headless process that skipped the prompt reports not_requested_headless.
Every fix for the helper inside iMessage Max.app names a permission in the app, such as "Open iMessage Max and turn on Full Disk Access." The permissions are Background activity, Full Disk Access, Contacts and Automation. For a bare executable, a fix names the System Settings switch to turn on. No fix names a terminal command.
capabilities.live_inbox describes the connection that calls diagnose. Only a legacy HTTP session receives notifications/imessage/new_messages, so only there is it supported (detail session_sse). On stdio and on the stateless 2026-07-28 lane it is unsupported, with detail poll_only while the helper's watcher runs or watcher_not_running. Call wait_for_messages, or poll get_messages_since, instead.
power says whether the Mac sleeps while idle, which stops the endpoint and the live inbox. status is stays_awake, idle_sleep (with sleep_minutes and a fix) or unknown. keep_awake is the companion's Keep this Mac awake setting and assertion_held whether the helper holds its power assertion now.
assistant_calls counts the tools/call requests this helper process has received from clients other than the companion app. The app tells you once, the first time it is above zero.
Release checks
Before a release, work through:
docs/RELEASING.md, the release procedure
docs/validation/2026-04-09-release-checklist.md, live spot checks
swift/Tests/iMessageMaxTests/SendManualValidation.md, manual send checks in Messages.app
One more note on sends:
When the destination is exact,
sendsends at once. There is no confirmation step.sendrefuses an ambiguous destination withstatus: "ambiguous". Theconfirmparameter is deprecated and ignored, and it remains only for compatibility. The user authorizes a send in their conversation with the agent and in the client's tool-approval UI, not on the server.
After a text send, the server checks chat.db for the sent message. Each status means:
status: "confirmed"means the server found the outbound row in chat.db within the verification window, with no error.verified_message_guidis the evidence. It is not a delivery receipt.status: "uncertain"means the server did not find the row within the polling window, either after the transport accepted the send or after Messages failed or timed out once the send had begun (disposition: "may_have_completed", with the error). The message may have been sent. Follow up withget_messagesbefore any resend.status: "mismatch"means the message went to a different chat than intended. Do not treat it as success.status: "failed_delivery"means the server found the message row with a delivery error recorded. The message did not deliver, andverified_message_guidplus the error code are the evidence.status: "partial_failure"means a multi-payload send dispatched some payloads before a later one failed.messagelists what was dispatched and what failed. Retry only the failed payload, never the whole call.status: "sent"means verification was unavailable because the database was unreadable. The only evidence is that the transport accepted the send.status: "pending_confirmation"means Messages accepted an attachment send, but the server could not confirm within the polling window that the file transfer finished. When text went with the file,text_statusis the text's own result.status: "failed"means the send failed.status: "ambiguous"means the name matches more than one number or email in Contacts.candidateslists the matches, and nothing was sent.
Disposition and retry_safe
Every send response also carries disposition and retry_safe. disposition describes the transport, that is, whether the Apple event went out. status describes what chat.db shows.
status | disposition | retry_safe |
confirmed, mismatch, sent |
|
|
uncertain |
|
|
failed_delivery |
|
|
pending_confirmation |
|
|
failed (transport) | from the send |
|
failed (validation / resolution) |
|
|
partial_failure | failing payload's disposition |
|
ambiguous |
|
|
{"status":"failed","disposition":"not_started","retry_safe":true,"error":"Could not find chat 'iMessage;-;does-not-exist' in Messages.app."}
{"status":"uncertain","disposition":"may_have_completed","retry_safe":false,"error":"Send operation timed out. Messages.app may be unresponsive.","message":"Messages.app did not acknowledge the send, and the message did not appear in chat.db within the verification window. The message may still have been sent, so do not resend it blindly. ..."}Notes:
pending_confirmationis a normal, non-fatal attachment state. It is not a hard failure.A send by
chat_idgoes to the existing conversation with that id.A send by
togoes through the person's existing 1:1 chat, whatever its service (iMessage, SMS or RCS), choosing the one with the newest message. Only a person with no 1:1 chat is sent to over iMessage.JSON-shaped tools return MCP
structuredContentas well as legacy text content for older clients.
Examples:
{"status":"confirmed","verified_message_guid":"...",...}means the outbound row was found in chat.db. It is not a delivery receipt.{"status":"pending_confirmation","success":false,...}means Messages accepted the attachment, but the server could not yet confirm that the transfer finished
Troubleshooting
Contacts showing as phone numbers
Run diagnose and read contacts.status. If it is denied, not_determined or not_requested_headless, open iMessage Max and turn on Contacts. If it is denied, also turn on iMessage Max in System Settings → Privacy & Security → Contacts. A bare imessage-max executable cannot hold a Contacts grant of its own, so use the helper inside the app. Names refresh within 30 seconds of a Contacts change. The server drops them as soon as access is revoked, and no restart is needed.
"Cannot read the iMessage database" / permission_denied
Full Disk Access is missing for the process that opens chat.db. For
the helper inside iMessage Max.app, macOS charges the access to the app,
so turn on iMessage Max. For a bare imessage-max launchd service, that
process is the binary itself. Stdio clients reach the helper through the
bridge, so they need no grant of their own. Only --direct makes the app
that spawns the binary need it. Run diagnose. For the helper inside the
app, its database.fix field reads "Open iMessage Max and turn on Full
Disk Access." For a bare executable, it names the executable and steps 1 to 3:
System Settings → Privacy & Security → Full Disk Access → add that executable (or its launching app).
If it is already listed, toggle it off and on. A grant bound to an earlier code signature looks present but does not work.
make signuses the Developer ID in your keychain when one exists, which gives the binary a stable identity so rebuilds keep the grant. An ad-hoc build does not. The first build after moving from the old self-signed "iMessage Max Dev" identity to Developer ID needs this toggle once.The grant applies only to newly launched processes. For the helper inside the app, open iMessage Max again: while Full Disk Access shows Not allowed, it restarts the helper and checks. For a bare service, run
cd swift && make restart, which runslaunchctl kickstart -k. A restart within 10 seconds of the last launch waits out launchd's throttle.To narrow it down yourself, run
sqlite3 -readonly ~/Library/Messages/chat.db 'pragma quick_check;'from a terminal.okmeans your terminal has access and the server process does not.unable to open database filemeans the grant is missing for your user.
make install ends with make verify-db, which calls diagnose over
HTTP and fails if database.accessible is false. Run it on its own after
changing Full Disk Access:
cd swift && make verify-db"Database not found" error
~/Library/Messages/chat.db does not exist. Sign in to iMessage and send or
receive one message.
Images show "attachment_offloaded" error
Some attachments are stored in iCloud, not on disk. list_attachments includes nested attachment summaries with available: true/false for each file. iMessage Max cannot download them: get_attachment returns attachment_offloaded with download_started: false, and retrying does not help. To download offloaded attachments, open the conversation in Messages.app. diagnose reports attachments_offloaded as degraded for this reason.
MCP client not loading the server
Check that the config file is valid JSON
Check that the binary path is correct
Reconnect or fully restart your MCP client
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MCP Client / │◄───►│ iMessage Max │◄───►│ chat.db │
│ Agent │ │ (Swift MCP) │ │ (SQLite) │
└─────────────────┘ └────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Contacts.app │
│ (CNContactStore)│
└─────────────────┘Requirements
macOS 15+ (Sequoia or later) on Apple silicon
Full Disk Access permission
Contacts permission (for name resolution)
Automation permission for Messages.app (send only)
Advanced setup
For HTTP mode, the local background service, development commands, and the contributor workflow, see the Swift README. Request bodies must arrive within 30 seconds. A stalled upload gets HTTP 408 with a JSON-RPC error body, and the server closes connections that are idle for 60 seconds. See also:
License
MIT
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Send and receive iMessage, SMS, RCS, and WhatsApp, and manage contacts, chats, and numbers.
Let AI agents securely read the text messages on your phone.
Business texting for AI agents: send texts, read threads, manage contacts and run campaigns.
Mac & Windows: let ChatGPT, Claude & Cursor use your email, calendar, iMessage, Teams, files.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables Claude to send and read iMessages on macOS, with smart contact lookup, message history retrieval, and cross-conversation search using natural language commands.6MIT
- AlicenseAqualityDmaintenanceEnables AI assistants to read iMessage history and send messages on macOS. Supports conversation listing, message search with keyword and semantic modes, contact lookup, and sending messages to existing conversations.1311MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI assistants to read, search, and send iMessages, manage contacts, and access attachments on macOS.11 npm1MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to read the entire Apple Messages (iMessage/SMS) history on a Mac through a read-only, batched tool that supports listing chats, retrieving transcripts, polling recent messages, and searching message bodies via REST or streamable HTTP MCP.MIT