| name | whatsapp-mcp-bridge-gotchas |
| description | Hard-won fixes for standing up the lharries/whatsapp-mcp bridge (whatsmeow + WhatsApp Web multidevice), encrypted, with send as a reversible dial. Covers the 403 media-download root cause (direct path stripped of its CDN auth query), the 405 client-outdated fix (bump whatsmeow + add context.Context to changed APIs), QR-vs-phone-code pairing, encrypt-at-rest without sudo (gocryptfs static binary), read-only vs send-enabled enforcement, and liveness/identity/verification gotchas (bridge freshness probe, @lid JID migration, outgoing REST sends absent from messages.db) and the outbound anti-spam discipline (no bursts to new contacts; device-removed recovery). 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:
- 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.
- 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)
- "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).
- 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.
- 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).