Skip to main content
Glama
wacli-mcp

wacli-mcp

wacli-mcp

A Model Context Protocol server that lets an AI assistant search and read a WhatsApp archive mirrored locally by wacli, and, only if you opt in, send plain text to a short list of recipients you name.

By default it is read-only. It never edits, deletes, reacts, marks as read, downloads media, back-fills history, or links an account; those code paths do not exist here. Keeping the archive current, and owning the WhatsApp connection, is left to wacli sync --follow, running as a separate process you control.

The design follows the MCP guidance in wacli's own companion integrations guide: wrap the JSON CLI, fix the store, allowlist a few read commands, pass arguments without a shell, bound everything, and treat retrieved content as untrusted. docs/assessment.md explains why this wraps wacli instead of using whatsmeow directly, and what was evaluated.

wacli and whatsmeow use WhatsApp's unofficial multi-device web protocol. They are not affiliated with WhatsApp or Meta, and WhatsApp's help center says linking an account to unofficial apps may result in a temporary or permanent ban. This project never links an account itself, but it is only useful with one that wacli has linked.

Tools

Tool

wacli command (always prefixed with --read-only --json)

list_chats

chats list

list_messages

messages list (optional chat, sender, time window, cursor)

search_messages

messages search (full-text, optional filters and up to 5 neighbouring messages)

search_contacts

contacts search

history_coverage

history coverage

archive_health

doctor (never with --connect)

Every tool carries readOnlyHint: true and openWorldHint: false. Results are wrapped with untrusted_content: true and a notice telling the model not to follow instructions found in messages.

With sending enabled (see Sending) one more tool appears:

Tool

wacli command

send_message

send text --to=<configured JID> --message=<text> --no-preview, delegated to wacli sync --follow

It carries readOnlyHint: false, idempotentHint: false and openWorldHint: true.

With several accounts configured (see Multiple accounts), every tool takes a required account argument limited to the configured aliases. With one account, tool signatures are unchanged.

Related MCP server: WhatsApp Read Agent MCP Server

Security model

Read-only access still exposes private conversations, and a model that reads attacker-controlled text can be steered by it (Invariant Labs showed exactly this against a WhatsApp MCP server). The layers, from outside in:

  1. No write tools by default. The exfiltration path in the Invariant Labs research was a send_message tool. It does not exist unless you enable it, and then only reaches recipients you listed (see Sending). Pair this server with other tools that can send data out (email, web requests, shell) only with care, since a prompt injection read through this server could use them.

  2. No network for wacli. By default every wacli call runs inside an empty network namespace (unshare -rn), so even a bug in a read path cannot talk to WhatsApp. The server refuses to start if that is unavailable, unless you set WACLI_MCP_NETWORK_ISOLATION=auto or off.

  3. wacli read-only mode. Every read call passes --read-only and WACLI_READONLY=1. The one exception is send_message, which still runs without network and can only hand a message to the sync daemon.

  4. Fixed command shape. The executable, store or account, and command allowlist are set by the operator. Arguments are single --flag=value tokens, free text goes after --, there is no shell, and the child gets a minimal environment (your API keys and tokens are not inherited).

  5. Bounds. Per-call timeout with process-group kill, stdout cap, concurrency cap, a per-minute rate limit, result limits, and per-field text truncation.

  6. Chat policy. Optional allowlist or denylist of chat JIDs, enforced on explicit filters and again on every result row.

  7. Output shaping. Only known fields are returned. Local media paths and WhatsApp CDN paths are dropped. Bidirectional-override, zero-width and control characters are stripped so text cannot disguise itself.

  8. Executable vetting. The wacli binary and its directory must not be writable by group or others; you can also pin its SHA-256.

  9. Audit log. Optional JSON-lines log (mode 0600) with tool name, outcome, duration and result counts. It never records arguments, because queries and names are personal data.

  10. HTTP transport (optional). Bearer token from a mode-0600 file, constant-time comparison, binding limited to loopback or Tailscale addresses, Host header allowlist (DNS rebinding protection), and browser Origin requests refused.

Things this server cannot protect against: an MCP client that shows results to someone else, other tools in the same agent session, and anyone who can read the wacli store directly (it is plain SQLite, protected only by file permissions).

Requirements

  • Linux with unprivileged user namespaces (for network isolation). Debian 13 works out of the box; Ubuntu 24.04 needs kernel.apparmor_restrict_unprivileged_userns=0.

  • Python 3.12+ and uv.

  • wacli 0.22 or newer (tested with 0.25.1), already authenticated by you with wacli auth, and ideally kept fresh with wacli sync --follow.

Install

git clone https://github.com/viniciusbregoli/wacli-mcp.git
cd wacli-mcp
uv tool install .
wacli-mcp check   # validates the configuration and runs a local `wacli doctor`

Configuration

All settings are environment variables read once at startup. None can be changed by the model.

Variable

Default

Meaning

WACLI_MCP_BIN

~/.local/bin/wacli, else wacli on PATH

Absolute path to wacli

WACLI_MCP_BIN_SHA256

unset

Refuse to start unless the binary matches

WACLI_MCP_STORE

wacli's default

Store directory (exclusive with WACLI_MCP_ACCOUNT)

WACLI_MCP_ACCOUNT

unset

Named wacli account

WACLI_MCP_NETWORK_ISOLATION

require

require, auto or off

WACLI_MCP_ALLOWED_CHATS

unset

Comma-separated JIDs; when set, only these chats are visible

WACLI_MCP_DENIED_CHATS

unset

Comma-separated JIDs that are never visible

WACLI_MCP_TIMEOUT_S

20

Per-call timeout (1 to 120)

WACLI_MCP_MAX_OUTPUT_BYTES

2097152

Cap on wacli stdout per call

WACLI_MCP_MAX_CONCURRENCY

2

Parallel wacli processes

WACLI_MCP_RATE_LIMIT_PER_MIN

60

Tool calls per minute

WACLI_MCP_MAX_TEXT_CHARS

4000

Truncation length for each text field

WACLI_MCP_AUDIT_LOG

unset

Absolute path of the JSON-lines audit log

WACLI_MCP_HOME

$HOME

HOME passed to wacli

WACLI_MCP_ACCOUNTS_FILE

unset

TOML file defining several accounts (see Multiple accounts)

Sending only (see Sending):

Variable

Default

Meaning

WACLI_MCP_ENABLE_SEND

unset

1 registers send_message; requires the two settings below

WACLI_MCP_SEND_RECIPIENTS

unset

Comma-separated alias=JID pairs, the only possible recipients

WACLI_MCP_STORE

Required with sending, so the daemon check looks at the right store

WACLI_MCP_SEND_CONFIRMATION

elicit

elicit asks the user to approve each message; none skips that

WACLI_MCP_SEND_MAX_CHARS

1000

Longest message accepted (1 to 4096)

WACLI_MCP_SEND_PER_HOUR

10

Sends per rolling hour (1 to 60)

WACLI_MCP_SEND_TIMEOUT_S

60

Time allowed for one send (10 to 300)

HTTP transport only:

Variable

Default

Meaning

WACLI_MCP_HTTP_HOST

127.0.0.1

Loopback or a Tailscale address; anything else is refused

WACLI_MCP_HTTP_PORT

8765

Port

WACLI_MCP_TOKEN_FILE

required

Mode-0600 file holding a bearer token of at least 32 characters

WACLI_MCP_ALLOWED_HOSTNAMES

unset

Extra Host names to accept, such as a MagicDNS name

Policy notes: with an allowlist, explicit chat filters must be exact JIDs, because names are resolved inside wacli. wacli may return rows for a chat's @lid identity when you ask for its phone-number JID, so list both forms if you use an allowlist. Rows are filtered after wacli applies limit, so a page can come back shorter than requested; hidden_by_policy reports how many rows were removed.

Multiple accounts

One server can serve several WhatsApp accounts, each a separate wacli store. Point WACLI_MCP_ACCOUNTS_FILE at a TOML file (owned by you, mode 600) with one table per account; deploy/accounts.example.toml is a starting point:

[accounts.personal]
store = "/home/me/.local/state/wacli"   # an existing store, by absolute path
label = "Personal phone"
denied_chats = ["15550000002@s.whatsapp.net"]

[accounts.personal.send]                # optional, needs WACLI_MCP_ENABLE_SEND=1
recipients = { me = "15550000001@s.whatsapp.net" }

[accounts.work]
wacli_account = "work"                  # a named wacli account
allowed_chats = ["120363000000000001@g.us"]

Key

Meaning

store

Absolute store directory. Exactly one of store or wacli_account

wacli_account

A name from wacli accounts list

label

Optional text shown to the model next to the alias

allowed_chats, denied_chats

This account's chat policy (lists of JIDs)

send.recipients

This account's only possible recipients, alias = "JID"

How accounts are kept apart:

  • Explicit selection. Every tool requires account, an enum of the aliases. There is no default account, so a call can never land on an archive the model did not name.

  • Pinned stores. wacli_account names are resolved once at startup with wacli accounts show (read-only, inside the empty network namespace) and every call then uses --store=<that directory>. Changing wacli's config.yaml or default account later cannot redirect an alias; restart the server to pick up changes. Two aliases on the same store are refused.

  • Per-account policy. Chat allowlists and denylists, send recipients, the hourly send limit, the duplicate guard and the sync daemon check all belong to one account. A recipient alias or JID configured for one account is refused when sending from another.

  • Two switches for sending. The file says who each account may message, and WACLI_MCP_ENABLE_SEND=1 in the environment turns sending on. send_message only lists accounts that have recipients.

  • Labelled results and audit. Results and audit entries carry the account alias.

WACLI_MCP_STORE, WACLI_MCP_ACCOUNT, WACLI_MCP_ALLOWED_CHATS, WACLI_MCP_DENIED_CHATS and WACLI_MCP_SEND_RECIPIENTS cannot be combined with the file; everything else (limits, audit log, isolation, send confirmation) still comes from the environment and applies to all accounts. The global per-minute rate limit and concurrency cap are shared, while send limits are per account. Each account needs its own wacli --store=<dir> sync --follow running for fresh data and for sending.

Adding a second wacli account without breaking the first: wacli accounts add <name> makes the first account it adds the default, so every wacli command run without --store or --account (including a single-account wacli-mcp without WACLI_MCP_STORE) switches to the new, empty store. Pin the existing archive by absolute path, as in the example above, before adding accounts; wacli-mcp check warns when a single-account setup is unpinned.

Connecting a client

Local clients should spawn the server over stdio. For Claude Code:

claude mcp add whatsapp-archive --scope user \
  --env WACLI_MCP_AUDIT_LOG="$HOME/.local/state/wacli-mcp/audit.jsonl" \
  -- wacli-mcp stdio

Any client that accepts a JSON server definition:

{
  "mcpServers": {
    "whatsapp-archive": {
      "command": "wacli-mcp",
      "args": ["stdio"],
      "env": { "WACLI_MCP_DENIED_CHATS": "15551234567@s.whatsapp.net" }
    }
  }
}

Remote clients over Tailscale

install -d -m 700 ~/.config/wacli-mcp ~/.local/state/wacli-mcp
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > ~/.config/wacli-mcp/token)
WACLI_MCP_HTTP_HOST="$(tailscale ip -4)" WACLI_MCP_TOKEN_FILE=~/.config/wacli-mcp/token wacli-mcp http

Clients connect to http://<tailscale-ip>:8765/mcp with Authorization: Bearer <token>. Traffic is plain HTTP inside the Tailscale tunnel, which WireGuard already encrypts. A hardened systemd unit is in deploy/wacli-mcp-http.service.

Sending

Sending is off unless you set WACLI_MCP_ENABLE_SEND=1. It exists for one job: letting an assistant send a short text you asked for to someone you named in advance. It is the one capability that can move data out of the archive, so a message passes all of these, in order, before wacli is run:

  1. Recipient allowlist. recipient must be an alias from WACLI_MCP_SEND_RECIPIENTS or one of its exact JIDs. Names, phone numbers and other JIDs are refused, so a model (or a message it read) cannot pick a new destination. Recipients must also be visible under the chat policy, or the server will not start.

  2. Message checks. Plain text up to WACLI_MCP_SEND_MAX_CHARS. Text with control or invisible characters (bidi overrides, zero-width spaces) is refused rather than cleaned, so what you approve is exactly what is sent. Lines starting with ----- are refused because the confirmation prompt uses that frame.

  3. Rate and duplicates. At most WACLI_MCP_SEND_PER_HOUR sends per hour, one at a time, and the same text to the same recipient is refused for 10 minutes.

  4. Daemon check. The server confirms that wacli sync --follow holds the store lock and its .send.sock exists. wacli then hands the message to that daemon over the Unix socket; the wacli child still runs in the empty network namespace. Without the daemon nothing is run, so this server never opens a second WhatsApp connection.

  5. User confirmation. With the default WACLI_MCP_SEND_CONFIRMATION=elicit, the client shows the user the recipient and the exact text (MCP elicitation) and nothing is sent unless they approve. Clients without elicitation support get an error and nothing is sent.

Outcomes are reported honestly. If wacli times out or says the message may still have gone through, the tool says the outcome is unknown, tells the model not to retry, and blocks the same text for 10 minutes. The audit log records the recipient alias, character count and outcome of every attempt, including refused ones, and never the text.

WACLI_MCP_SEND_CONFIRMATION=none removes step 5. Use it only for a client without elicitation, with a recipient list you would be comfortable with a prompt injection writing to (for example, only yourself): anything the model reads can then influence what it sends to those recipients.

Example, sending only to your own number and one group:

claude mcp add whatsapp --scope user \
  --env WACLI_MCP_STORE="$HOME/.local/state/wacli" \
  --env WACLI_MCP_ENABLE_SEND=1 \
  --env WACLI_MCP_SEND_RECIPIENTS="me=15551234567@s.whatsapp.net,family=120363000000000000@g.us" \
  --env WACLI_MCP_AUDIT_LOG="$HOME/.local/state/wacli-mcp/audit.jsonl" \
  -- wacli-mcp stdio

archive_health reports send_ready: true once the daemon is running for that store.

Keeping the archive fresh

This server never syncs. Results describe what wacli has stored; message results (list_messages, search_messages) and archive_health include last_sync_at, which says when a sync last completed. Run the sync yourself, for example as a service (sending also needs it running):

wacli sync --follow

wacli's read commands work while sync --follow holds the store lock.

Limitations

  • WhatsApp gives linked devices history on a best-effort basis; see history_coverage.

  • Message content is returned to the model, which is the point of the tool. Use the chat policy for chats that should never leave the machine.

  • wacli's JSON fields are not a formal API. Integration tests pin the behaviour of the tested version; run them after upgrading wacli.

  • Media is not available: there is no download tool, by design. Sending is plain text only: no files, replies, mentions or link previews.

  • Delivery is not confirmed. A successful send_message means WhatsApp accepted the message, not that the recipient's phone received it.

Development

uv sync
uv run ruff check . && uv run ruff format --check .
uv run pytest

Unit tests use a fake wacli executable. Integration tests (-m integration) run the real wacli binary (WACLI_MCP_TEST_BIN, default ~/.local/bin/wacli) against a throwaway store created in a temp directory with synthetic data, inside an empty network namespace. They never open your real store. CI downloads the pinned official wacli release and verifies its checksum before running them.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to interact with your personal WhatsApp account, allowing them to search messages and contacts, retrieve chat history, and send messages to individuals or groups. Uses WhatsApp Web API with local data storage for privacy and security.
    16 npm
    ISC
  • F
    license
    A
    quality
    C
    maintenance
    Enables read-only access to your own WhatsApp history and attachments via the Model Context Protocol, allowing AI assistants to enumerate chats, page history deterministically, and retrieve approved attachment bytes.
    7
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI assistants to read WhatsApp chat history from a SQLite archive, optionally scrape live chats, and send messages through a chain of safety guards.
    35 npm
    MIT
  • A
    license
    B
    quality
    B
    maintenance
    Enables AI clients to read and send WhatsApp chats, messages, contacts, groups, polls, reactions and media through two isolated adapters — a personal linked-device runtime and the official Meta Business Graph API — each with per-profile credentials, audit/history storage and managed media. Writes are gated behind prepare/confirm workflows, and inbound media or history is restricted to verified provider IDs and bounded, allowlisted downloads.
    1
    MIT