A single-container, Go-native Model Context Protocol server for WhatsApp, built on whatsmeow.
- One Docker image. Everything the server needs runs in a single container — no sidecars, no
docker-composebundle. Pair-brokerage, MCP transport, and session persistence are all in-process. - One Go binary. The library layer (
whatsmeow) and the MCP layer (mark3labs/mcp-goor the official Go SDK) are linked into a single static-ish executable. No inter-process HTTP hop, no shared-secret between processes, no second language runtime. - Full tool parity with
FelixIsaac/whatsapp-mcp-extended. All 41 tools that project exposes are available, with equivalent names and argument shapes to minimise migration friction.
Design-wise the container is also intended to be safely embeddable behind an external approval/permission/auth layer that proxies MCP tool calls — it should not assume it is the security boundary.
- Web dashboard bundled inside the container (pair flow is programmatic; any UI lives upstream).
- Multi-account multiplexing inside one process (one instance = one WhatsApp account; run N containers for N accounts).
- Full historical message export (bounded by what WhatsApp's multidevice sync delivers).
- Broadcast lists, voice/video calls.
- Coupling to any specific deployment target (compose, Kubernetes, systemd, local dev all must work).
41 tools, same names as the upstream reference, grouped for readability.
This section is the target surface, not the shipped one. Names below without a ✅ are requirements still to be built. For what the binary actually registers today, see SUPPORTED.md — the authoritative source is
internal/tools/register.go,internal/mcptools/, andinternal/mcp/ping.go.
Messages / chats (8)
list_messages, list_chats, get_chat, get_direct_chat_by_contact, get_contact_chats, get_last_interaction, get_message_context, request_history
Sending (4)
✅ send_message (text), ✅ send_file (image / video / audio / document /
sticker envelopes), ✅ send_audio_message (voice note / PTT). Planned, not
yet implemented: send_reaction.
Both media tools take a media_path reference rather than bytes or a local
filesystem path — see "Media byte routes" below.
Message editing / state (3)
edit_message, delete_message, mark_read
Media (1)
download_media
Contacts (6)
search_contacts, get_contact_details, list_all_contacts, set_nickname, get_nickname, remove_nickname, list_nicknames (local-only alias/notes store)
Groups (8)
get_group_info, create_group, add_group_members, remove_group_members, promote_to_admin, demote_admin, leave_group, update_group
Polls (1)
✅ send_poll — named send_poll, not create_poll, to match the
send_message convention every other outbound tool follows. Noted in
CHANGES.md.
Presence (2)
set_presence, subscribe_presence
Profile / privacy (4)
get_profile_picture, get_blocklist, block_user, unblock_user
Newsletters / channels (3)
follow_newsletter, unfollow_newsletter, create_newsletter
Each tool's input schema, output shape, and error semantics MUST match the upstream Python reference unless the Go binding surfaces a strictly better type (e.g. typed JID instead of loose strings). Breaking divergences require a note in CHANGES.md.
Beyond the parity surface, the Go build also exposes get_conversation — a native, additive read tool with no upstream equivalent. It is the canonical "what's the latest with this person?" front door: it merges every chat for a contact across their phone JID (…@s.whatsapp.net) and privacy LID (…@lid) into one newest-first, de-duplicated timeline of enriched messages. The lower-level per-JID read tools (e.g. get_direct_chat_by_contact, get_contact_chats) are unchanged and remain available for power use. Like the other cache reads, it is subject to the not_paired gate.
Polls also gain two native read/write tools with no upstream equivalent — vote_poll and get_poll_results — because a poll nobody can answer or count is not a feature. Both are documented in CHANGES.md; get_poll_results reads a tally the ingestor accumulates locally, since neither whatsmeow nor the WhatsApp protocol can be asked for a poll's standings.
In addition to the parity surface, the Go build exposes 2 native pairing tools — pairing_start, pairing_complete — for agents that drive pairing through the MCP transport. They are the only pairing path (the admin HTTP SSE endpoints were removed in 99b0ce7); concurrent flows are serialised at the wa layer (adminMu + ErrPairInProgress). These tools, plus ping, are exempt from the not_paired gate so that a pre-pair agent can bootstrap itself.
download_media, send_file and send_audio_message diverge from the upstream reference in one deliberate way: bytes never pass through the tool call, in either direction. download_media returns a descriptor; the send tools take one. See §Media transfer.
+--------------------------- container ---------------------------+
| |
| Go binary (static-ish, distroless / debian-slim runtime) |
| |
| whatsmeow client <---- WS (TLS) ----> web.whatsapp.com |
| | |
| | in-process calls |
| v |
| MCP server (stdio or HTTP/SSE on :PORT) |
| | |
| +---- POST /media + GET /media/{sha256} |
| on the SAME :PORT, same bearer |
| (bytes only; MCP cannot carry them) |
| |
| SQLite + blobs: |
| /data/session.db (whatsmeow sqlstore: device, ratchet, ...) |
| /data/cache.db (chats, messages, contacts, nicknames, polls)|
| /data/media/ (attachment blobs in + out, content-addressed)|
| |
| ffmpeg (shelled out, optional, for audio transcode to Opus) |
| used by send_audio_message; -slim image only |
| |
+------------------------------------------------------------------+
- The MCP tool layer calls
whatsmeowfunctions directly — no internal RPC. - Two SQLite files in a single
/datavolume: one owned bywhatsmeow(crypto + protocol state), one owned by this project (mirror of chats / messages / contacts + local metadata like nicknames). - Only
/datais persistent. The rest of the filesystem MAY be mounted read-only by the operator.
- HTTP/SSE MCP on
BIND_ADDR:PORT(primary). Suitable for out-of-container MCP clients and for proxying behind another service. Authentication is mandatory when HTTP is enabled (see Security). - stdio MCP (local-dev mode). When
TRANSPORT=stdio, the binary speaks MCP on stdin/stdout. Intended for Claude Desktop /mcpCLI / test harnesses. The media byte routes are HTTP-only and therefore unavailable in stdio mode:download_mediastill stores the file under$DATA_DIR/media, but with no way to POST bytes in, the media send tools can only forward media the container already holds.
MCP has no useful way to carry binary payloads, and putting attachment bytes into an agent's context is wasteful even when it is possible. Media is therefore split in two:
download_media(MCP tool) — input{ chat_jid, message_id }. Fetches the attachment from WhatsApp's CDN, stores it content-addressed at$DATA_DIR/media/<sha256>.<ext>, and returns{ media_path, mime, size, filename, sha256 }. The call is idempotent: a digest already stored is a cache hit with no network I/O. Errors:not_found(message not cached),no_media(message carries no attachment),media_unavailable(locator expired or CDN failure),internal.GET /media/{sha256}(HTTP) — mounted on the same listener as/mcp, behind the same bearer auth. SendsContent-Type,Content-Length,Content-Disposition,ETag,Last-ModifiedandCache-Control; supportsRange(206).401without a valid bearer,404for an unknown or evicted digest. The path segment MUST be validated as a 64-character hex digest before any filesystem access.POST /media(HTTP) — the inbound mirror, same listener, same bearer auth. The request body is the raw file;Content-Typenames the mimetype (sniffed from the leading bytes when absent orapplication/octet-stream) and?filename=names the file. Answers201with the same{ media_path, mime, size, filename, sha256 }descriptor,400for an empty body,413aboveMEDIA_MAX_UPLOAD_BYTES(default 100 MiB),405for a non-POST/PUT method. A caller's mistake MUST NOT be reported as500. Storage is content-addressed and idempotent, so re-uploading identical bytes is a cache hit. The payload MUST be spooled to disk rather than buffered whole in memory.send_file/send_audio_message(MCP tools) — takemedia_path(a/media/<sha256>reference, a bare digest, or a gateway URL ending in one), never bytes, never base64, and never a local filesystem path. A message this server sends is mirrored into the cache with the plaintext digest of what went on the wire, sodownload_mediaresolves our own sends out of the store.
These are the only non-MCP routes the container serves, and they exist solely because MCP structurally cannot transfer bytes. They do not reopen the :8082 admin API removed in 99b0ce7.
Voice notes (send_audio_message, PTT) are Ogg/Opus or they are nothing: WhatsApp will accept another codec on the wire and then fail to play it, which is indistinguishable from a silent send failure. The contract is therefore two-sided and MUST fail closed:
- Input already Ogg/Opus (decided by the magic number, not the declared mimetype) → uploaded as-is.
- Otherwise,
FFMPEG_PATHresolves to an executable → transcoded to mono 48 kHz Opus, and the converted blob is stored so the cached row anddownload_mediaagree with what the recipient received. - Otherwise →
invalid_argumentnaming the probed path and the-slimimage, rather than an unplayable send.
send_file applies the same rule with a wider allow-list (audio/ogg, audio/opus, audio/mpeg, audio/mp4, audio/aac, audio/amr play as attachments), and sends without PTT.
media_direct_path (migration 004) is what makes downloads durable: the pre-signed media_url expires, the direct path does not. It is only present on the live protobuf at ingest time and CANNOT be backfilled, so messages ingested before that migration may only be downloadable until their URL expires. download_media MUST say so explicitly and point at cache_sync rather than returning an opaque failure.
Retention: $DATA_DIR/media is a cache, not a system of record. It is bounded by MEDIA_MAX_BYTES (least-recently-requested evicted first) and optionally MEDIA_TTL, swept at startup and every MEDIA_SWEEP_INTERVAL. An evicted download costs a re-download, not data. An evicted upload has no origin to re-fetch from, so the send tools MUST report a missing digest as not_found with instructions to upload again, and callers should upload shortly before sending rather than staging bytes for long periods.
The container MUST expose pairing programmatically so that an external UI (or an MCP-driven agent) can broker it without shipping its own whatsmeow client. Pairing is driven entirely through the MCP tools below, on top of wa.Client.StartPairing. Only one flow may be open at a time; a second caller receives pair_in_progress until the first flow ends.
The admin HTTP surface that previously fronted pairing (
POST /admin/pair/start,POST /admin/pair/phone,POST /admin/unpair, on:ADMIN_PORT) was removed in99b0ce7. Everything that can be a tool is a tool.
These two tools target agents that authenticate through the same bearer-token MCP transport (and any auth gateway proxying it). They are exempt from the not_paired gate.
pairing_start— input{ "phone"?: string }. Withoutphone, opens a QR flow and returns{ "status": "awaiting_scan", "code": "<raw QR>", "timeout_ms": <int> }. Withphone, also requests a phone linking code and returns{ "status": "awaiting_phone_link", "linking_code": "ABCD-EFGH", "code": "<latest QR>", "timeout_ms": <int> }. If the first QR has not been emitted within an internal timeout (rare; whatsmeow normally emits within ~1s), the QR-mode response degrades to{ "status": "pending" }and the caller should pollpairing_completefor the code. Errors:already_paired,pair_in_progress,not_pairing(only whenphoneis supplied — indicates the freshly-opened flow was already torn down by a concurrent unpair),internal.pairing_complete— input{ "wait_seconds"?: integer (0..120, default 60) }. Polls the in-progress flow.wait_seconds=0returns the latest cached event without blocking (status snapshot). Otherwise blocks up towait_seconds, coalescing rotation events and returning either a terminal status (success,timeout,error,client_outdated,scanned_without_multidevice) orpendingcarrying the newest rotation code.not_pairingindicates no flow is active. Onsuccess, the response also carriesjidandpushnamefromwa.Status().
Until pairing succeeds, every MCP tool call MUST return a structured error with a stable code (not_paired), except for ping, pairing_start, and pairing_complete which remain callable pre-pair so agents can detect state and drive the link flow over MCP.
/datais the only persistent volume; nuke it to fully reset the device identity.whatsmeowis configured with its SQLitesqlstorepointed at/data/session.db. Ratchet state rotates on every message, so only one process may own the volume at a time — the binary acquires an exclusiveflockon/data/.lockat startup and exits non-zero if it is already held.- The cache DB at
/data/cache.dbstores the same entities Felix's reference ships: chats, messages, contacts, nicknames, FTS index over message text. It additionally stores poll ballots and votes (polls,poll_options,poll_votes, migration006), which have no reference equivalent because poll tallies exist nowhere else — seeget_poll_resultsin CHANGES.md. Schema documented indocs/schema.md.
The container MUST surface these events to the outside world (for external orchestrators that drive reconnect UI):
logged_out— WhatsApp unpaired the device (remote). Follow-on tool calls returnnot_paireduntil re-pair.stream_replaced— another process connected with the same keys; this instance is toast.temporary_ban— includes expire duration.connection_failure— includes the whatsmeow reason code.connected,disconnected— transport-level state.
Event delivery:
- Mirrored as MCP notifications (
notifications/session) when the MCP transport supports them. - Summarised by the
pingandcache_sync_statustools.
When logged_out or stream_replaced fires, the process MUST NOT silently try to recover with stale credentials. It stays in not_paired state and waits for explicit re-pair.
| Var | Default | Purpose |
|---|---|---|
TRANSPORT |
http |
http or stdio |
BIND_ADDR |
0.0.0.0 |
HTTP bind address |
PORT |
8081 |
Serves /mcp, POST /media and GET /media/{sha256} |
DATA_DIR |
/data |
Persistent state dir (session.db, cache.db, media/) |
LOG_LEVEL |
info |
debug|info|warn|error |
LOG_FORMAT |
json |
json or text |
AUTH_TOKEN |
(unset) | Required bearer token for every HTTP route (/mcp and /media/). REQUIRED in http mode. |
MTLS_CA_FILE, MTLS_CERT_FILE, MTLS_KEY_FILE |
(unset) | Not implemented. Setting any of them in http mode is a fatal startup error. No TLS listener exists, so they never encrypted anything or verified a client certificate. If mTLS is ever built, it will be additive to AUTH_TOKEN, never a replacement. |
WHATSAPP_DEVICE_NAME |
whatsapp-mcp |
Shown on the user's phone after pairing. |
FFMPEG_PATH |
/usr/bin/ffmpeg |
ffmpeg binary used to transcode audio to Opus for send_audio_message (and for send_file audio WhatsApp cannot play). Probed per call, not at startup, so an ffmpeg bind-mounted into a running container is picked up without a restart. Present → arbitrary input is transcoded; absent → Opus input is required and anything else is refused with invalid_argument, never sent unplayable. |
ENABLE_PPROF |
false |
Exposes /debug/pprof when true. |
MEDIA_MAX_BYTES |
1073741824 |
Cap on $DATA_DIR/media. Over the cap, least-recently-requested blobs are evicted. 0 = unlimited. |
MEDIA_TTL |
(unset) | Go duration; evicts media older than this. Unset/0 disables age-based eviction. |
MEDIA_SWEEP_INTERVAL |
1h |
Retention sweep period. A sweep also runs at startup. |
MEDIA_MAX_UPLOAD_BYTES |
104857600 (100 MiB) |
Largest single POST /media body. A hard refusal (413), not an eviction trigger: it bounds what one request can push onto the volume between sweeps. |
No long-lived secrets in env vars in production: operators should deliver AUTH_TOKEN via a secret store mount (tmpfs file + file:// reference) rather than -e.
- Non-root. Runs as UID 1000 (
appuser). Dockerfile builds this in; noUSER rootin the runtime stage. - Capabilities dropped. No NET_ADMIN, no SYS_ADMIN.
- Read-only root filesystem compatible.
/dataand/tmpare the only writable paths; the binary must not write anywhere else. - Minimal base image. Distroless static or
debian:bookworm-slim— decision documented indocs/image.md. No shell in the distroless variant. - Signed images. Releases pushed to
ghcr.io/angel-manuel/whatsapp-mcp-dockerand signed withcosign. Consumers pin by digest. - Reproducible-ish builds.
go build -trimpath -buildvcs=false+ pinned base image digests; goal is byte-identical rebuilds from the same commit. - Mandatory auth on HTTP. Starting in
httpmode withoutAUTH_TOKENis a fatal error at startup. The bearer check rejects every request when the configured token is empty, rather than accepting an empty one. - TLS terminates upstream. The listener is plaintext HTTP; the bearer token is the only auth this process enforces. Operators needing transport encryption or client-certificate auth must front it with a reverse proxy. Config that claims otherwise fails closed at startup rather than serving plaintext under an mTLS-shaped name.
- Outbound egress. The process only needs to reach WhatsApp endpoints; operators running under a strict egress policy should allow at minimum:
*.whatsapp.netweb.whatsapp.commmg.whatsapp.net,media-*.whatsapp.net(media CDN) Exact hostname list published indocs/egress.mdand kept in sync with whatsmeow's client payload.
- No telemetry. The binary does not phone home.
- Structured JSON logs to stdout; one event per line; includes
connection_id(if passed via env) and a stableevent_type. GET /healthz— unauthenticated liveness probe (HTTP server up and routing); drives the container HEALTHCHECK viawhatsapp-mcp --healthcheck.ping(MCP tool) — readiness: liveness + pairing state. Exempt from thenot_pairedgate, so it answers before the device is linked.cache_sync_status(MCP tool) — cache counts, last ingested event, current/most-recent sync run.- Prometheus exposition is not implemented yet.
Dockerfile— multi-stage: Go builder → distroless-static (preferred) runtime. OptionalDockerfile.slimvariant with shell + ffmpeg for audio transcoding use cases (send_audio_messagewith non-Opus input).Makefileorjustfiletargets:build,test,lint,image,run-local.- CI (GitHub Actions): unit tests,
go vet,staticcheck, image build + push on tag, cosign sign. - Versioning: SemVer;
v0.xuntil tool surface is stable. Each release publishes both a:vX.Y.Ztag and an immutable digest. - SBOM published alongside each image (
syft→ SPDX).
- MCP protocol: track the current MCP spec; bump major on breaking protocol changes.
- whatsmeow: pinned to a known-good commit in
go.mod; upgrade cadence ~monthly or when WhatsApp forces it. Each whatsmeow bump gets a release note describing observed behavioural changes. - Tool surface: argument/output shape stable within a minor version; additions allowed in minor, breaks require a major.
- Unit tests for all pure helpers (JID parsing, pagination, schema migrations).
- Integration tests against whatsmeow's in-memory store for non-networked paths.
- A manual end-to-end harness (
tests/e2e/) that runs against a real WhatsApp account via a dev phone; not part of CI, documented indocs/e2e.md.
- Primary MCP transport default:
httpvsstdio. Current lean:httpfor hosted,stdiofor local dev (TRANSPORT=env switches). - Incoming-message delivery: polling via
list_messages(since=...)is guaranteed; MCP notifications for new messages are desirable but some clients do not subscribe. - Auto-ffmpeg vs BYO-Opus: ship both image variants and let operators pick.
- Keeping the cache DB small: eviction policy for old media metadata / messages older than N days.
whatsmeow: https://github.com/tulir/whatsmeow- Reference Python MCP: https://github.com/lharries/whatsapp-mcp
- Extended reference (tool parity target): https://github.com/FelixIsaac/whatsapp-mcp-extended
mcp-go: https://github.com/mark3labs/mcp-go- MCP spec: https://modelcontextprotocol.io