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.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add CarlosCaPe/octorato --skill whatsapp-mcp-bridge-gotchas --agent claude-codeInstalls 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 automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/carloscape-whatsapp-mcp-bridge-gotchas)More formats (shields.io, HTML) on the badges page.
---
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).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!