Fixes for the lharries/whatsapp-mcp bridge: 403 media download, 405 client-outdated, QR vs phone-code pairing, encrypt-at-rest, @lid JID migration, outbound anti-spam. Load before installing or debugging a personal WhatsApp MCP.
Installs into .claude/skills of the current project.
Are you the author of Whatsapp Mcp Bridge Gotchas?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/carloscape-whatsapp-mcp-bridge-gotchas)
---
name: whatsapp-mcp-bridge-gotchas
description: "Fixes for the lharries/whatsapp-mcp bridge: 403 media download, 405 client-outdated, QR vs phone-code pairing, encrypt-at-rest, @lid JID migration, outbound anti-spam. Load before installing or debugging a personal WhatsApp MCP."
metadata:
type: reference
---
# WhatsApp MCP bridge (lharries/whatsmeow) gotchas
Lived 2026-06-10 standing up `lharries/whatsapp-mcp` for a personal number, read-only and encrypted. The repo works but ships against a pinned whatsmeow that WhatsApp now rejects, and its media download is broken in a subtle way. The fixes below are the root causes, not workarounds.
## 1. 403 on media download (the big one). Root cause: stripped CDN auth
**Symptom:** `/api/download` returns `download failed with status code 403` for every image/audio/doc, including a message received live (so it is NOT URL expiry).
**Root cause:** the bridge stores the full media `url` and reconstructs the direct path with `extractDirectPathFromURL`, which **strips the query string** (`?ccb=...&oh=...&oe=...&_nc_sid=...`). Those query params are the **CDN auth tokens**. whatsmeow's `Download` builds the download URL as `mediaHost + directPath`, so a path without its query is unauthenticated and the CDN returns 403.
**Fix (one line):** in `extractDirectPathFromURL`, keep the query. Return `"/" + parts[1]` WITHOUT the `strings.SplitN(pathPart, "?", 2)[0]` strip. This recovers history-synced media too (no need to re-forward the message), because the stored URL already carries valid tokens.
## 2. 405 "Client outdated" on connect. Bump whatsmeow + add context
**Symptom:** bridge connects then `Client outdated (405) connect failure (client version: ...)`, websocket closes.
**Fix:** the pinned whatsmeow is stale. `go get go.mau.fi/whatsmeow@latest && go get go.mau.fi/util@latest && go mod tidy`, rebuild. The current whatsmeow API added a `context.Context` first arg to several calls the bridge uses; add `context.Background()` to: `client.Download`, `client.GetGroupInfo`, `client.Store.Contacts.GetContact`, `sqlstore.New`, `container.GetFirstDevice`. Build errors point to each line.
## 3. Pairing: prefer the phone code over the QR
The terminal QR (qrterminal half-block) is often unscannable from a chat surface and rotates every ~20s, so it times out. **Phone-number pairing is far more reliable.** whatsmeow supports `client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")`, which returns an 8-char code the operator types in WhatsApp > Linked Devices > Link with phone number. Patch the QR loop to call it when an env like `WA_PAIR_PHONE=<intl digits>` is set. The bridge links as its own device; the operator's WhatsApp Web in a browser stays logged in (multidevice allows several).
## 4. Encrypt at rest without sudo
`gocryptfs` built from source needs `libssl-dev` (sudo). Skip that: download the **prebuilt static binary** from the gocryptfs GitHub releases (`*_linux-static_amd64.tar.gz`), it has no deps and FUSE is usually present (`/dev/fuse` + `fusermount`). Init a cipher dir, mount it, and `ln -s <mount> whatsapp-bridge/store` so the bridge writes messages.db + the whatsmeow session encrypted. Passphrase via `-passfile` from a 600 file or, better, the SO keyring (`secret-tool`).
## 5. Send enforcement is a dial, not a fact (read-only vs send-enabled)
Read-only is a DEPLOYMENT DECISION with two layers, both reversible:
1. **Tool registration in the server source** (`main.py`): the send functions (`send_message`, `send_file`, `send_audio_message`) live in `whatsapp.py` and the bridge exposes `/api/send` regardless. Removing their `@mcp.tool()` registration hides them from the MCP schema; re-adding it restores them. The capability never left the stack.
2. **Harness deny** in `settings.json` `permissions.deny`: `mcp__whatsapp__send_*`. Note: an agent cannot remove its own deny rules (the auto-mode classifier blocks self-widening of permissions, and it also blocks tunneling the send through curl while the deny stands). Flipping this layer is an operator-only step, by design.
Gotcha that costs a whole exchange: a skill doc that says "read-only by design" describes the decision AT WRITE TIME, not the current system. Before declaring "no se puede enviar", grep the live code (`grep "def send" whatsapp.py`, `grep "api/send" main.go`). If the operator asks for the capability, the answer is to flip the dial at the root (re-register tools + operator removes deny), never to build workarounds around your own lock.
## 6. Operational
The Go bridge must stay alive (whatsmeow keeps the multidevice session); run it as a `systemd --user` service with `loginctl enable-linger`. The gocryptfs mount must be remounted on boot too. Both are prerequisites for the MCP server to read.
## 7. Liveness, identity, and verification gotchas (post-mortem tested)
1. **"The MCP answers" is NOT "the bridge is alive."** The python MCP server only reads the DB; the Go bridge is what syncs. The real liveness probe is data freshness: `SELECT MAX(timestamp) FROM messages` vs now. If it lags hours, the bridge is down even though every MCP query "works". On reconnect with a live session, WhatsApp re-delivers the queued backlog in minutes, no re-pairing needed (check `whatsapp.db` mtime to guess session health before assuming re-pair).
2. **Contacts migrate to `@lid` JIDs.** A person's chat under `<phone>@s.whatsapp.net` can freeze in time while their new messages land under `<numeric>@lid`. Searching only the phone JID yields a false "no new messages". Sweep by content/media across all chats, or match the @lid chat by conversation context, before claiming silence.
3. **Outgoing sends via `/api/send` are NOT persisted to `messages.db`** (only event-handler traffic is stored). A DB read-back after sending returns empty; that is a storage gap, not a delivery failure. The delivery evidence is the bridge log line `Message sent true ...` (whatsmeow ack). Verify sends against the log, and final-verify visually on a phone/WhatsApp Web.
## 8. Sending documents: octet-stream without FileName arrives "damaged"
The stock `sendWhatsAppMessage` routes any non-image/audio/video extension to `application/octet-stream` and builds the `DocumentMessage` with `Title` only. Receiving clients use `FileName` for the saved file's name and extension; without it they save an extension-less blob and report the document as damaged or undownloadable. Fix: map real mime types per extension (pdf, xml, doc/docx, xls/xlsx, zip, txt) and set BOTH `Title` and `FileName` on the `DocumentMessage`.
Discipline that would have caught it before a third party did: the FIRST outward use of any newly enabled send path is a self-canary. Send the artifact to your own self-chat, download it back through the same stack, and verify it opens (bytes match), BEFORE pointing the path at a real recipient. A server ack ("Message sent true") proves acceptance, not artifact integrity at the receiver.
## 9. Capture the undo handle: persist send IDs and expose revoke
The stock `/api/send` handler discards `resp.ID` from `client.SendMessage`, outgoing REST sends are not stored (gotcha 7.3), and whatsmeow's revoke (`client.BuildRevoke(chatJID, types.EmptyJID, messageID)` = delete-for-everyone of your own message) REQUIRES that id. Net effect: anything sent wrong is unrecoverable from the bridge; the only cleanup is manual on a phone. Fix in three moves: return `message_id` + `chat_jid` in the send response, `StoreMessage(...)` the outgoing message so it is listable later, and add an `/api/revoke` endpoint. General rule: an outward action should record the handle needed to undo it at the moment it executes, not when you first need it.
## 10. WhatsApp anti-spam kills automated bursts to NEW contacts (device removed, then account restriction)
Enforcement observed in production, two stages: (1) ~60 seconds after back-to-back sends to numbers with NO prior chat (tell: `failed to persist outgoing message: FOREIGN KEY constraint failed` = no chat row existed), the server emits `device removed stream error` and the bridge session self-deletes; (2) immediate re-pair attempts (a QR + 2 codes inside an hour) read as a bot re-linking and escalate to an ACCOUNT-level restriction (hours). The next rung is a permanent number ban.
The scoring signals stack: first-contact number + near-duplicate message bodies + burst timing (parallel tool calls land seconds apart) + unofficial-client fingerprint (whatsmeow). Messaging an EXISTING chat has never triggered it.
Rules that keep the number safe: first commercial contact with a new number goes by email, web form, or voice call, never the bridge. If the operator explicitly orders a bridge send to a new number: max ONE per session, unique body, never two outward sends in the same parallel block (space them minutes apart). After a `device removed`: do NOT retry pairing immediately; wait until the operator confirms the phone app shows no restriction, then a SINGLE `WA_PAIR_PHONE` code attempt (pairing section above). The bridge's lane is reading and low-volume conversation with existing chats.
## 11. Read tools die on `dict_type` validation: dataclasses returned where dicts are declared
Symptom on pydantic >= 2.13 / mcp >= 1.27: `Error executing tool list_chats: N validation errors for list_chatsOutput ... Input should be a valid dictionary [type=dict_type, input_value=Chat(jid='...')]`. The server still runs and the bridge is healthy; only the read tools are dead, which reads as "no data" and sends you scraping the DB by hand.
Root cause: `whatsapp.py` returns plain `@dataclass` objects (`Chat`, `Contact`, `Message`, `MessageContext`) while `main.py` annotates the tools `-> List[Dict[str, Any]]`. FastMCP validates the RETURN against that annotation, and a dataclass is not a dict. `model_dump()` is the wrong reflex here (that is pydantic BaseModel); dataclasses need `dataclasses.asdict()`.
Fix at the boundary, not per-tool: one recursive `_jsonable()` in `main.py` (`is_dataclass` -> `asdict`, recurse dicts/lists, `datetime` -> `isoformat`) applied to every return. Two sibling defects of the same family surface once you look: `list_messages` actually returns a formatted STRING (`format_messages_list`) so its annotation must be `-> str`, and `get_chat` / `get_direct_chat_by_contact` return `None` on not-found so they need `Optional[Dict[str, Any]]`.
Verify without an MCP restart: call each tool function directly in the venv and validate its output with `TypeAdapter(get_type_hints(fn)['return'])`, which is exactly what FastMCP does. Include the not-found path in the cases, that is where the `Optional` gap hides. The patch only goes live when the MCP server process restarts (it is a stdio subprocess of the agent session, holding the old module in memory), so a passing code-level test plus a still-failing live tool call is the expected intermediate state, not a failed fix. Do NOT try to force the reload by killing the server process: tested 2026-07-24, the harness does not respawn a dead stdio MCP server, not after the kill and not on the next tool call. The tools deregister and every live session sharing that server loses WhatsApp until a session restart. Restarting the session is the only path, and killing it first just adds an outage.
## Chat-context gate
Entering a live chat without loading its history is the no-context-chatbot failure: the message ignores what the thread already said. Post-@lid-migration a WhatsApp contact lives under TWO JIDs (`phone@s.whatsapp.net` holds our sends, `NNN@lid` holds their replies, mapped in `whatsapp.db:whatsmeow_lid_map`); a read or content-search that only covers one JID returns a false "not there". The reflex is mechanical, not discipline: `scripts/g__pretool-mcp__chat-context.py` (PreToolUse on `mcp__whatsapp__send_message`, Registry `COMMS.chat-context-before-send`) denies the FIRST send to a chat per 30-min window and prints the merged tail of both JIDs; the re-issued send passes (block-once, sentinel in `~/.cache/octorato/chat-context/`). A missing bridge DB does not bypass the gate; it denies with a "read the chat another way" note.
Related: [[mcp-stack-setup]], [[phi-aware-rag-ingestion]] (sensitive-data ingestion), [[sops-age-git-encryption]], [[canary-symbiont]] (live-test the path once).