MCP server for BlueBubbles — access iMessage from any MCP client.
Built from scratch with no third-party MCP dependencies beyond the official mcp SDK and httpx.
- Python 3.11+
- A running BlueBubbles server with API access enabled
git clone https://github.com/metaember/bluebubbles-mcp.git
cd bluebubbles-mcp
uv syncAdd to your MCP client config (e.g. Claude Code ~/.claude/settings.json):
{
"mcpServers": {
"bluebubbles": {
"command": "uv",
"args": ["--directory", "/path/to/bluebubbles-mcp", "run", "python", "-m", "bb_mcp.server"],
"env": {
"BLUEBUBBLES_URL": "https://your-bluebubbles-server",
"BLUEBUBBLES_PASSWORD": "your-server-password"
}
}
}
}By default the server can message anyone. Set BLUEBUBBLES_WRITE_ALLOWLIST to a
comma-separated list of phone numbers and/or emails to restrict every write
(send, reaction, attachment, typing, scheduling, group edits, delete/leave) to
those recipients. It is enforced server-side, so an MCP client — including one
reaching the HTTP transport — cannot bypass it.
Behavior:
- Unset → unrestricted (backward compatible). Set but empty (
"") → deny all, a safe failure mode. - Numbers are matched in normalized E.164 form, so
(555) 123-4567and+15551234567are equivalent; emails match case-insensitively. - For a group chat, every participant must be on the list or the write is blocked. A chat whose participants can't be resolved is denied (fail-closed).
- Read tools are not restricted, and the message-GUID writes
(
edit_message,unsend_message) aren't covered — they only act on an already-sent message and can't reach a new recipient. ABLUEBUBBLES_READ_ALLOWLISTmay be added later.
BlueBubbles can drive Messages two ways. AppleScript works on any install with no extra setup but only sends plain texts/attachments. The Private API (requires the BlueBubbles Helper + partially disabled SIP) additionally unlocks tapbacks, edit/unsend, typing indicators, read receipts, group management, threaded replies, and SMS sending.
On startup the server reads /server/info and adapts itself to what's actually
available:
- Sends use the Private API when present, and fall back to AppleScript when
not — so
send_messageworks on a bare install instead of erroring. - Tools that require the Private API (reactions, edit/unsend, typing, read receipts, group management, iMessage/FaceTime availability checks) are hidden from the tool list when it's unavailable, so a client never sees a tool that could only fail.
Override the auto-detection with BLUEBUBBLES_PRIVATE_API:
"BLUEBUBBLES_PRIVATE_API": "auto" // default: introspect /server/info
// "true" — force-enable (assume the Private API is set up)
// "false" — force-disable (AppleScript only; hide Private API tools)If /server/info can't be read at startup, detection fails open (assumes the
Private API is available) so a transient blip doesn't hide half the toolset.
The same /server/info read also discovers the user's own iMessage address,
surfaced by the get_my_address tool. Override it if detection is wrong or you
run multiple handles:
"BLUEBUBBLES_MY_ADDRESS": "+15551234567" // or you@icloud.comMessage and chat data from BlueBubbles carries raw phone numbers and emails, not
names. To save the model from constantly cross-referencing numbers, read
responses are enriched with a contactName beside each handle, and
find_contact / find_chats let it reach people by name instead of number.
Resolution is lazy and cached per session: a response's addresses are looked up
in a single batched /contact/query the first time they're seen, then reused.
Disable it (for leaner responses or if your contact DB is slow) with:
"BLUEBUBBLES_RESOLVE_NAMES": "false" // default: enabledfind_contact / find_chats still work when this is off — they're explicit.
An agent can reply to a conversation against a stale snapshot — it read the thread
a while ago, a new message arrived in the gap, and it answers without seeing it.
The freshness guard enforces, server-side and per agent, that a send into a chat
only goes through if that agent read the chat within the last hour and nothing
new has arrived since. (A consequence: you must read an existing chat before
sending into it — good hygiene for replies. Starting a brand-new conversation via
create_chat is unaffected; it's for first contact only and, with the guard on,
refuses to reach an existing chat — use send_message for those.)
How it works. The guard keeps a per-agent watermark — the newest message each
agent has seen in each chat (optimistic concurrency, like an ETag on the thread):
- Reading a chat with
get_chat_messagesrecords the watermark. Only that tool records — a scan likeget_unread_chatsdoesn't — so to reply you must deliberately open the actual thread. - Before a send, the server re-checks the chat's live newest message. If anything (from anyone — the other person, you on another device, or another agent) arrived since your watermark, or your read is older than the TTL, the send is blocked with a note to re-read and re-plan.
- Your own send advances your watermark, so sending doesn't block your next send.
See docs/freshness-guard.md for the full design.
On by default. Disable with BLUEBUBBLES_FRESHNESS=off. Tuning:
"BLUEBUBBLES_FRESHNESS": "off", // default: on
"BLUEBUBBLES_FRESHNESS_IDENTITY": "session", // how agents are told apart (default)
"BLUEBUBBLES_WATERMARK_TTL_SECONDS": "3600", // how long a read stays "fresh" (default 1h)
"BLUEBUBBLES_WATERMARK_MAX_AGENTS": "10000" // memory backstop (default 10000)The freshness check is per agent, so it needs to tell agents apart.
BLUEBUBBLES_FRESHNESS_IDENTITY selects how:
session(default): use the MCP transport session — automatic and zero-config for stdio (one agent) and direct HTTP (one session per connecting client). Over HTTP this relies on stateful sessions (FastMCP's default); the server refuses to start if you combine session-identity freshness with stateless HTTP, since every send would be blocked.meta: identify agents by a per-agentagentIdthe caller stamps into request_meta. Required only behind a pooling proxy/airlock, where every agent shares one transport session sosessioncan't distinguish them. Seedocs/freshness-guard.mdfor the airlock contract.
Message reads return a compact projection by default — the fields an
assistant needs (guid, text, handle + contactName, isFromMe,
timestamps, attachment names, reaction/reply linkage) instead of the full raw
BlueBubbles objects, which cuts token usage substantially. Pass extended=true
on any read tool to get the complete raw fields.
Message reads (get_chat_messages, search_messages, get_recent_messages)
also accept from_address to filter by who sent each message — pass a phone
number / email, or "me" for the user's own messages. Filtering is by true
sender (the user when isFromMe, otherwise the message's handle), so it's
correct in both 1:1 and group chats.
| Tool | Description | Annotations |
|---|---|---|
ping |
Check server connectivity | read-only |
get_server_info |
Server info and health | read-only |
get_my_address |
The user's own iMessage address (to identify their own messages) | read-only |
list_chats |
List conversations by recent activity | read-only |
get_chat |
Chat details with participants | read-only |
get_chat_messages |
Messages from a chat | read-only |
search_messages |
Search by text, chat, time range | read-only |
get_message |
Single message by GUID | read-only |
get_contacts |
All contacts | read-only |
lookup_contact |
Look up name by phone/email | read-only |
find_contact |
Find contacts by name (phone/email unknown) | read-only |
find_chats |
Find chats involving a contact by name | read-only |
check_imessage |
Check iMessage registration | read-only |
check_facetime |
Check FaceTime registration | read-only |
query_handles |
List/search known handles | read-only |
get_handle |
Get a handle by address | read-only |
get_focus_status |
Contact's Focus / Do Not Disturb status | read-only |
find_my_devices |
Find My — your devices' locations | read-only |
find_my_friends |
Find My — friends' locations | read-only |
list_scheduled_messages |
List future messages | read-only |
get_scheduled_message |
Get one scheduled message by ID | read-only |
get_recent_messages |
Messages from last N minutes across all chats | read-only |
get_unread_chats |
Chats with unread messages + their latest messages | read-only |
get_attachment_info |
Attachment metadata | read-only |
download_attachment |
Download attachment as base64 | read-only |
get_group_icon |
Download a group chat's icon | read-only |
mark_chat_read |
Send read receipt | idempotent, open-world |
mark_chat_unread |
Mark chat unread (local) | idempotent |
rename_group |
Rename a group chat | idempotent |
set_group_icon |
Set a group chat's icon | idempotent |
start_typing |
Show typing indicator | open-world |
stop_typing |
Stop typing indicator | open-world |
send_message |
Send to an existing chat (1:1 or group) | open-world |
create_chat |
Start a new 1:1 conversation by phone/email | open-world |
create_group_chat |
Create a group chat + first message | open-world |
send_attachment |
Send a file attachment | open-world |
send_multipart |
Send text + attachments as one message | open-world |
send_reaction |
Tapback reaction | open-world |
edit_message |
Edit a sent message | open-world |
schedule_message |
Schedule a future message | open-world |
update_scheduled_message |
Update a scheduled message | open-world |
add_participant |
Add to group chat | open-world |
unsend_message |
Retract a message | destructive, open-world |
remove_participant |
Remove from group chat | destructive, open-world |
leave_chat |
Leave a group chat | destructive, open-world |
remove_group_icon |
Remove a group chat's icon | destructive, open-world |
delete_message |
Delete a single message | destructive, open-world |
delete_chat |
Delete a conversation | destructive, open-world |
delete_scheduled_message |
Cancel scheduled message | destructive, open-world |
The server speaks stdio by default. Set MCP_TRANSPORT=streamable-http to serve
over Streamable HTTP instead, so any
HTTP-capable MCP client can connect at http://<host>:8000/mcp. The Docker image
sets this for you.
docker build -t bluebubbles-mcp .
docker run --rm -e BLUEBUBBLES_URL -e BLUEBUBBLES_PASSWORD -p 8000:8000 bluebubbles-mcpAs a Compose service — credentials live in this container's own environment (an
env_file or Docker secrets), so they stay isolated to this tool:
services:
bluebubbles-mcp:
build: .
environment:
BLUEBUBBLES_URL: ${BLUEBUBBLES_URL}
BLUEBUBBLES_PASSWORD: ${BLUEBUBBLES_PASSWORD}
restart: unless-stoppedNotes:
- The HTTP endpoint is unauthenticated, so don't expose it publicly — keep it on a
private/internal network (and drop the published
ports:if a co-located client reaches it over the Compose network). BLUEBUBBLES_URLmust be reachable from inside the container — use a hostname/IP the container can resolve (e.g. the BlueBubbles host's LAN address orhost.docker.internal), notlocalhost.
MIT