This file tracks each point where the Go implementation's tool surface
diverges from the upstream Python reference
(reference/whatsapp-mcp-extended/whatsapp-mcp-server/). Every entry
must be strictly non-breaking for MCP clients OR justified as a
"strictly better Go-native type" per REQUIREMENTS.md §"Tool surface".
The Go build adds tools that the Python reference does not expose. These are strictly additive — clients that don't call them see no change.
- Reference: pairing is brokered out-of-band; the Python server assumes the device is already linked when MCP clients connect.
- Go: agents that authenticate through the MCP transport can drive
the pair flow themselves via two tools —
pairing_start(opens the flow, returns the first QR payload or, withphone, a linking code) andpairing_complete(polls/waits for terminal). Both bypass thenot_pairedgate. They are the only pairing path; concurrent flows are serialised at the wa layer (wa.adminMu+ErrPairInProgress). - Why: REQUIREMENTS.md mandates programmatic pairing behind an external auth/proxy layer; exposing the flow as MCP tools lets that proxy mediate pairing alongside every other tool call without a second transport.
- Reference: no equivalent. The Python server exposes only per-JID
lookups (
get_direct_chat_by_contact,get_contact_chats,get_last_interaction), so reconstructing a contact's full thread when WhatsApp has split it across their phone JID (…@s.whatsapp.net) and privacy LID (…@lid) takes several calls plus manual merging. - Go: a single front-door tool —
get_conversation(contact, limit?, page?, before?, after?)— resolves the contact to all of its identities (via thejid_aliasesmapping the ingestor learns from each message's alternate address) and merges every chat into one newest-first, de-duplicated timeline of enriched messages (resolved sender name, explicit direction, delivery status). Subject to thenot_pairedgate like every other cache read. - Why: strictly additive — the lower-level per-JID tools are unchanged for power use. It only collapses the common "what's the latest with this person?" workflow into one call instead of six, which the multi-identity split otherwise forces.
- Reference: returns
{ success, message, file_path }, wherefile_pathis a path on the machine running the MCP server. That only works when the server and the client share a filesystem — which is precisely what a containerised deployment does not do. - Go: returns
{ media_path, mime, size, filename, sha256 }.media_pathis/media/<sha256>, an HTTP route served on the same port and behind the same bearer token as/mcp. The bytes are fetched out-of-band with a plainGET; the tool result never contains them, and never base64. - Why: MCP cannot carry binary payloads usefully, and pushing an
attachment through an agent's context window is wasteful even when it
is technically possible. Splitting the flow into "tool returns a
pointer, HTTP returns the bytes" lets a gateway stream a file straight
to the caller without it ever entering the model's context. Storage is
content-addressed, so
sha256doubles as the cache key and the integrity check, and repeat calls cost nothing. - Note: an attachment cached before the
media_direct_pathcolumn existed (migration004) has only an expiring CDN URL, which cannot be backfilled. Those calls fail withmedia_unavailableand a message telling the caller to runcache_syncand retry.
- Reference: both take
media_path, an absolute path on the machine running the MCP server, and read the file off that filesystem. As withdownload_media, that assumes the client and the server share a disk. - Go:
media_pathis the same/media/<sha256>referencedownload_mediahands back. Bytes get into the container throughPOST /mediaon the MCP port, behind the MCP bearer token, and the tool call carries only the pointer. A bare<sha256>and a gateway URL ending in one are accepted too, so a caller does not have to reconstruct the path. Argument names are otherwise unchanged, andsend_fileadds optionalmedia_type,caption,filenameandreply_to_id. - Why: same reasoning as
download_media, in the other direction — and it makes forwarding free: themedia_pathfrom adownload_mediacall can be passed straight tosend_filewith no bytes moving at all. - Also: the reference's
send_fileshells out to ffmpeg for every audio input and errors with "you likely need to install ffmpeg" when it is missing. The Go tools probeFFMPEG_PATHper call and only transcode what WhatsApp cannot play; with no ffmpeg, Opus is still sendable and everything else is refused withinvalid_argumentrather than uploaded as an unplayable message.
- Reference:
send_reaction(chat_jid, message_id, emoji)returning{ success, chat_jid, message_id, emoji, action, error }. The bridge builds the reaction with its own JID as the target's sender (whatsapp-bridge/internal/whatsapp/messages.go:255), soMessageKey.FromMeis always true — reacting to someone else's message in a group produces a misattributed key. - Go: same three arguments, plus an optional
sender_jid. The target message's author is resolved from the local cache (an explicitsender_jidwins; a message we sent resolves to the empty JID, which is whatsmeow's "this is mine" signal), so group reactions carry the correctMessageKey.FromMe/Participant. A target that is neither cached nor supplied returnsnot_foundrather than being guessed at. The result is{ message_id, chat_jid, target_id, emoji, action, sent_ts }—message_idis the reaction stanza's own id andtarget_idthe message reacted to, which the reference conflated into one field.action("add"/"remove") is preserved, as is the empty-emoji removal convention. Failures use the structured error codes (see §"Error surface") instead of{ success: false, error }. Newsletter/channel chats are rejected withinvalid_argument: they needNewsletterSendReactionand aMessageServerIDthe cache does not capture, so a normal reaction stanza would be silently dropped. - Why: the reference's key-building bug is invisible to the caller
and produces a wrong reaction on the wire; resolving the author is the
only correct way to build the key, and the cache already has it. The
split
message_id/target_idis required to report the send at all — a reaction has its own stanza id.
- Reference: names one poll tool,
create_poll, and stops there — the Python server can create a poll but cannot vote in one or read the result. - Go: three tools. The creation tool is named
send_poll, notcreate_poll, so every outbound tool reads the same way (send_message,send_poll); the argument shape is otherwise what the reference name implies.vote_pollandget_poll_resultsare additive — no reference equivalent, so no client can be broken by them. - Why: a poll nobody can answer or count is not a feature, and the
two additions are not optional extras but the other half of the one the
reference names.
get_poll_resultsin particular can only exist here: neither whatsmeow nor the WhatsApp protocol exposes a way to ask the server for a poll's standings, so the tally is accumulated locally from the vote events the ingestor decrypts (migration006). Every result carries acaveatfield saying so — votes cast before this device was linked, or while the container was down, are not counted and cannot be recovered.
- Reference: returns a bare JSON array of Chat dicts.
- Go: returns
{ "chats": [Chat, ...] }. - Why: MCP's
structuredContentis specified as an object, and having a declaredoutputSchemaof typeobjectis how the registry advertises shape to clients. Wrapping lists in a one-field envelope is the minimum change that keeps schemas valid without changing element shapes.
- List wrapping: same rationale as
list_chats; returns{ "messages": [Message, ...] }. - Sender filter: reference accepts
sender_phone_number: strand matches against the rawmessages.sendercolumn (which in the Python schema is a plain phone-number string). The Go cache stores a typed JID on the sender column, so the tool input issender_jidinstead ofsender_phone_number. Callers holding only a phone number should resolve it throughget_direct_chat_by_contactfirst. querysemantics: the reference performs a SQLLIKE %q%scan. The Go tool uses the FTS5 index declared in001_init.up.sql, with the user's query wrapped in a phrase match to approximate substring behaviour. Stop-words and tokenizer differences may cause a Python LIKE match to differ from a Go FTS match at the margins; if that becomes a problem the FTS path can be made a LIKE fallback.
- Reference: message dicts carry no reaction data; the Python bridge does not ingest incoming reactions at all.
- Go:
MessageDTOgainsreactions, a list of{ emoji, sender, sender_name, is_from_me }, populated bylist_messages,get_message_context,get_last_interaction, andget_conversationin a single batched query per call. The key is omitted entirely when a message has no reactions, so existing clients see a byte-identical payload for the common case. Our own reaction reports an emptysenderwithis_from_me: true— the cache stores it under a canonical empty sender key so the live, history-sync, andsend_reactionpaths cannot produce duplicate rows. - Why: strictly additive. A reaction is often the only response a message gets; without this an agent has to infer it or report nothing.
- Reference: looks up the target by
messages.idonly (assumes stanza IDs are globally unique). - Go: our messages table PK is
(chat_jid, id)so the same id can recur across chats. The tool resolves the target withORDER BY chat_jid ASC LIMIT 1for determinism, then pages context scoped to thatchat_jid. Input schema is unchanged.
- Reference:
sender_phone_number: str, LIKE-matches against chat JIDs. - Go:
contact_jid: str. Attempts an exact-match first (so a full JID like14155552671@s.whatsapp.nethits the primary key path); falls back to a LIKE substring match for legacy phone-number inputs, preserving the reference's flexibility. Group JIDs (@g.us) are rejected withinvalid_argument— the tool only resolves 1:1 chats.
- Cosmetic: the reference used a bare
jidparameter, overloaded with "could be a contact, could be a chat". The Go version names itcontact_jidto match the task-level shape and reduce ambiguity. Output shape (list of Chat) is wrapped in{ "chats": [...] }for the same reason aslist_chats.
- Same rationale as above. The reference declared a return type of
strinmain.pybut actually returned a Message dict from the underlying module; the Go tool returns a Message-shaped object directly with amessageJSON schema, closing the gap.
- Reference:
Message.to_dict()exposesmedia_type, which is non-null only for downloadable envelopes. Anything else — including a poll — is indistinguishable from plain text. - Go: every message carries
kind(text,image,video,audio,document,sticker,poll,other) in addition to the unchangedmedia_type. Strictly additive: clients that ignore it see exactly the reference shape. - Why:
vote_pollandget_poll_resultstake the poll's message id, andlist_messages/get_message_contextare where a caller gets message ids. Withoutkind, an incoming poll arrives as an ordinary message whose content happens to be the question, so the id is unfindable and the receive-a-poll-then-vote flow cannot be driven at all.
All read-side tools use the structured error contract introduced in
internal/mcp/mcp.go:
not_paired— gated by the existing pairing middleware; unchanged.not_found— missing chat / message / contact.invalid_argument— malformed inputs (bad pagination, unparseable ISO-8601 timestamps, group JIDs on direct-only tools).internal— unexpected SQLite errors the tool couldn't attribute to user input.
download_media adds two codes of its own:
no_media— the message exists but carries no attachment. Retrying will never help; the caller picked the wrong message.media_unavailable— the attachment exists but its bytes could not be fetched (expired locator, CDN failure). Recoverable, usually by re-ingesting the message withcache_sync.
The reference raises Python exceptions or returns None for the same
cases; the Go shape is strictly more informative.