Skip to main content
Glama
rubickthemagus

whatsapp-mcp

README.md
# whatsapp-mcp

An MCP server that reads your WhatsApp history from the macOS desktop app's own
database — **no QR pairing, no protocol reimplementation, no account risk** —
with optional, tightly-gated replying.

## The technique

Every other WhatsApp MCP server runs [whatsmeow](https://github.com/tulir/whatsmeow)
or Baileys: a reverse-engineered WhatsApp Web client. That means scanning a QR
code before you can read a single message, keeping a second copy of your entire
history, and accepting a genuine risk of an account ban.

For **reading**, none of it is necessary.

WhatsApp's macOS desktop app stores your message history as **unencrypted
SQLite**:

```
~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite
```

It is a plain Core Data store, owned by your user, readable by any process
running as you. So this server splits the two operations by their risk:

| | Mechanism | Risk |
|---|---|---|
| **Read** | The desktop app's own SQLite file | None. Official client, never written to. |
| **Send** | A ~150-line whatsmeow bridge, session only | Real — opt-in, off by default. |

Reading works the moment you install it. Sending is a separate flag.

## Five things that will bite you

These cost real debugging time. All are pinned by regression tests.

### 1. The write-ahead log

The live database has a WAL. Reading the main file alone **silently omits recent
messages**; opening it read-write risks corrupting the app's state.

Copy `db` + `-wal` + `-shm` to a temp directory and open the *copy*, which
replays the WAL. Counter-intuitively, `mode=ro` is **wrong** here — a read-only
handle cannot replay a WAL and will serve you stale rows. The read-only
guarantee comes from never opening the source at all, which is asserted by a
test comparing the source's size and mtime after a full tool sweep.

Also delete a stale `-wal` in your temp copy when the source no longer has one,
or checkpointed rows come back from the dead.

### 2. `strftime` in a SQL comparison silently returns nothing

```sql
-- returns 0 rows, no error, on a database whose newest message is 4 minutes old
WHERE ZMESSAGEDATE + 978307200 > strftime('%s','now','-30 days')
```

`strftime` returns **TEXT**. SQLite orders every number before every string, so
the comparison is false for every row. Observed live: "0 messages in 30 days"
against a database that actually held 283.

The fix is not `CAST(... AS INTEGER)` — it is to keep `strftime` out of
comparisons entirely and bind Python integers, so a query written later cannot
forget the cast.

(Timestamps are Core Data epoch: seconds since 2001-01-01. Add `978307200`.)

### 3. `ZMESSAGECOUNTER` is not the message count

It reported **10** for a chat holding **419** rows, and **173** for one holding
**3,484**. Wrong by more than an order of magnitude. Count the rows.

### 4. `ZLASTMESSAGETEXT` is not text

It holds serialized protobuf on **65 of 66** chats on a real account — so a chat
list built from it surfaces base64 noise. Read the newest actual message
instead.

### 5. Filter in SQL, before `LIMIT` — never in Python after

Most group members carry an **empty-string** name. SQLite sorts `''` first, so
`LIMIT 500` returned nothing but blanks and a Python-side `if name` filter
dropped them all: **0 contacts returned when 358 existed.**

This one passed every synthetic fixture test. Only real data caught it.

> The pattern behind 3, 4 and 5: WhatsApp's denormalized summary columns do not
> contain what their names promise. Verify each against the underlying rows.

## Install

```bash
./install.sh          # prompts: 1 = read-only, 2 = read + reply
```

Registers with Claude Code and Claude Desktop.

**Claude Desktop needs Full Disk Access** (System Settings → Privacy & Security)
or every call fails. Note that Claude Desktop rewrites its config file from
memory when it quits, so add the connector while the app is **closed** — an
entry added while it runs gets silently dropped on exit.

For reply mode, pair once in a real terminal:

```bash
cd bridge && ./whatsapp-bridge -pair
```

QR codes expire in ~20–30 seconds, so have your phone already on
**Linked Devices** before you look. A `<stream:error code="515"/>` immediately
after `<pair-success>` is **normal** — it means "restart required" and whatsmeow
reconnects through it automatically.

## Tools

**Read** (always): `list_chats`, `list_messages`, `search_messages`,
`get_chat_context`, `list_contacts`, `get_group_members`, `stats`.
Date arguments accept `7d`, `24h`, `2w`, or an ISO date.

**Reply** (only with `--enable-send`): `draft_reply`, `send_message`.
In read-only mode these are **absent from the tool list**, not present and
refusing.

## How replying is gated

An instruction the model is asked to follow is not a control. So:

- `draft_reply` returns the exact text and **sends nothing**, issuing an HMAC
  token bound to that precise `(chat_id, text)` pair.
- `send_message` recomputes the binding and rejects any mismatch — change one
  character and the token dies.

The model cannot send text the user has not seen, enforced by code rather than
by prompt.

Plus: **reply-only scope** (recipients resolve from existing local history, so a
phone number can never be supplied freehand and proactive messaging is
unreachable), a rate limit, and an audit log of successful sends.

## Limits

- **History only goes back to when you linked WhatsApp Desktop.** Older messages
  live on your phone. That is WhatsApp's multi-device sync window, not something
  this code controls.
- **Media is mostly not on disk** — on a real account, 22 of 4,938 media items
  had a local file path. Media is reported by type and caption; files are not
  served.
- **No reply threading** — `ZPARENTMESSAGE` is NULL on every row.
- **macOS only.** The path and Core Data schema are specific to the macOS
  desktop app.

## Security note

Your WhatsApp history is a world-readable file in your home directory. Any
process running as your user can already read it. **This server does not grant
access — it packages it**, with typed tools, row limits, read-only snapshots and
the send gate above. Worth understanding either way.

## Development

```bash
uv run pytest
```

Tests run against a synthetic fixture database and need neither WhatsApp nor any
real messages.

## Licence

MIT

TDQS

A3.5/5.0

Scored across 7 tools

Disambiguation5/5

Each tool has a distinct retrieval role: chats, chat-scoped messages, full-text search, context expansion, contacts, group membership, and database health. The potential overlaps among list_messages, search_messages, and get_chat_context are clearly separated by their descriptions.

Naming Consistency4/5

Most tools follow a snake_case verb_noun pattern like list_chats, list_messages, and search_messages. The lone 'stats' breaks the verb_noun convention, and get_group_members could have been list_group_members for stricter parallelism.

Tool Count5/5

With seven tools, the server is well-scoped and each tool addresses a distinct need for inspecting WhatsApp data. No tool feels redundant or extraneous.

Completeness4/5

The read/search workflow is well covered: chats, messages, search, context, contacts, group members, and health checks. The main gaps are direct single-message lookup and any send/manage operations, but for a read-only database inspection surface these are minor and workaroundable.

Maintenance

ActivityMaintained
ResponsivenessNo issues