Apple Messages MCP
apple-messages-mcp
Local MCP server that lets a Cursor or Grok Bot agent read your Messages.app database on the user's Mac (iMessage + SMS), and optionally send a reply through AppleScript.
There is no cloud inbox and no extra phone number. Reads stay on localhost. Read-only by default. Scoped shut by default. With no MESSAGES_SCOPE and no MESSAGES_ALLOW_UNSCOPED=1, list/search/thread/send/watch return SCOPE. A personal bot that wants the whole inbox sets one line: MESSAGES_ALLOW_UNSCOPED=1.
See DESIGN.md for schema notes, the threat model, Phase 2 watcher hook, and agent instructions.
Requirements
macOS with Messages.app (the live
chat.dbpath)Node.js 22+
Full Disk Access for the app that launches this server (Cursor, or Terminal if you test from a shell)
Automation permission for Messages only if you turn send on
Linux CI uses a tiny fixture SQLite database. You do not need Messages.app to run npm test.
Install
git clone https://github.com/dh-repo/apple-messages-mcp.git
cd apple-messages-mcp
npm install
npm testThe server speaks MCP over stdio (leave this to Cursor; do not write logs to stdout):
npm run build
node dist/index.jsDev: npx tsx src/index.ts. Same binary is a local CLI for the smoke checklist and the Phase 2 watcher:
npx tsx src/index.ts status
npx tsx src/index.ts list
npx tsx src/index.ts thread --chat-id 12 --limit 20
npx tsx src/index.ts search snacks
npx tsx src/index.ts watch --interval 3000Full Disk Access
Open System Settings → Privacy & Security → Full Disk Access.
Enable Cursor (and Terminal if you smoke-test here).
Quit and reopen that app. TCC is applied at launch.
If
messages_statusreturnsfda_likely_missing: trueorPERMISSION, this step is not done.
Without FDA, macOS returns EPERM for ~/Library/Messages/chat.db even to the file's owner.
Cursor / Claude Desktop config
Cursor Add MCP server / mcp.json (same shape):
{
"mcpServers": {
"apple-messages": {
"command": "node",
"args": ["/ABS/PATH/TO/apple-messages-mcp/dist/index.js"],
"env": {
"MESSAGES_DB_MODE": "copy",
"ENABLE_SEND": "0",
"REDACT_PREVIEWS": "0"
}
}
}
}A checked-in copy lives at examples/cursor-mcp.json. Point args at your clone.
Claude Desktop: paste the same mcpServers block into claude_desktop_config.json.
Dev without a build: npx tsx src/index.ts.
Environment
Default is scoped shut. Pick one:
"MESSAGES_SCOPE": "Family, 12""MESSAGES_ALLOW_UNSCOPED": "1"Variable | Default | Purpose |
| unset | Comma/semicolon tokens: display name, |
|
|
|
|
| Override for fixtures. |
|
| Copy + WAL replay. |
|
|
|
|
| Bodies become |
| unset | Watcher runs this executable with one JSON event on stdin. |
|
| Watcher includes a redacted preview. |
Tools
Tool | What it does |
| Can we read |
| Recent chats, or only the allowlist if one is set. |
| Messages for a |
| Two-phase substring search (plain |
| Registered only when |
IDs in responses are SQLite ROWIDs: chat_id, message_id, handle_id, attachment_id. Handles are phone numbers and emails, not Contacts names.
Copy this into the agent's MCP instructions: the block in DESIGN.md §8.
Watcher (Phase 2)
Not push, and not part of the MCP stdio stream. A second process polls chat.db (and optionally fs.watchs the WAL) for new ROWIDs — all chats, or only the allowlist if one is set — then writes JSON lines:
npx tsx src/index.ts watch --interval 3000
# {"type":"messages.ready","chat_id":…,"newest_message_id":…}
# {"type":"messages.new","chat_id":…,"count_new":1,"preview":null}Pipe that into your own wake script, or set MESSAGES_WAKE_HOOK to examples/wake-hook.sh. The host should start an agent turn and call messages_get_thread. Do not treat this as a Slack-style channel.
Smoke-test checklist (Mac)
npm testpasses (fixture-only; safe on any machine).Grant FDA, restart Cursor, reload the MCP server.
Call
messages_status.readable: truefda_likely_missing: falseunscoped: falseunless you setMESSAGES_ALLOW_UNSCOPED=1Optional: set
MESSAGES_SCOPEand confirmscope.active: trueandscope.chatsmatches
messages_list_chatswithout scope env returnsSCOPEandscope.candidates.messages_get_threadwith achat_idfrom a scoped or unscoped list returns recent lines. At least sometext_sourcevalues may beattributedBodyon current macOS.text_source: "guess"means the decoder fell back to printable bytes.messages_searchwith a word you know exists returns hits across chats, or empty +truncated: truewhen the Tahoe window missed.messages_sendis absent fromtools/listwhileENABLE_SENDis unset.Optional:
ENABLE_SEND=1, Automation allowed, send a one-line test, confirm it in Messages.app. Leave send off afterward.npx tsx src/index.ts status(CLI) matches the MCPmessages_statuspayload.npx tsx src/index.ts watch --interval 2000prints onemessages.readyline and, on a new message,messages.newwithout plaintext.
Inspector, if you want a GUI without Cursor:
npx @modelcontextprotocol/inspector npx tsx src/index.tsWhat this is not
Not Inkbox / Linq / a hosted agent number.
Not a push notification service. The Phase 2
watchprocess polls /fs.watchschat.dband wakes the host — see DESIGN.md.Not a way to dump
chat.dbto a server.
License
MIT. See LICENSE.