14 KiB
WhatsApp MCP Server (whatsapp)
Overview
A Node.js MCP server that exposes WhatsApp as a set of tools for the LLM, using whatsapp-web.js + Puppeteer (headless Chromium).
Server name: whatsapp
Transport: stdio (spawns node scripts/whatsapp_mcp/index.js)
Location: scripts/whatsapp_mcp/index.js
Capabilities
| Capability | Enabled |
|---|---|
| List chats and groups | ✅ |
| Read messages from a chat | ✅ |
| Search messages by keyword | ✅ |
| Search contacts by name | ✅ |
| Send messages | ✅ |
| Send media (image/video/audio/document, from file or URL) | ✅ |
| Download received media (photos, videos, documents) | ✅ |
| Logout / reset session without restart | ✅ |
| Address a contact by phone number (no lookup needed) | ✅ |
| Edit/delete messages | ❌ not implemented |
⚠️ Important notes on WhatsApp
- whatsapp-web.js is unofficial: it drives WhatsApp Web through Chromium. WhatsApp may ban the number in case of heavy or anomalous use.
- Safe use: reading your own groups and sending individual messages is in the tolerated grey area. Do not use it for spam or bulk automation.
- Recommended number: using a secondary number or a parallel WhatsApp Business account reduces the risk.
Tools
All tools are callable as mcp__whatsapp__<tool>; the table lists the bare <tool> names.
| Tool | Parameters | Description |
|---|---|---|
status |
(none) | Connection status as a plain-language report: the state, what it means, and step-by-step fix instructions when not operational. Cross-checks the live socket (getState()) to catch silently dropped sessions |
get_qr |
(none) | QR code to scan with the phone — path/URL to a PNG or HTML page, with ASCII fallback (only when status = QR_READY) |
logout |
(none) | Ends the session, clears the cached credentials on disk and re-initializes the client → new QR without restart. Use it when the session has expired/got stuck or to link a different phone |
list_chats |
max_chats (int, default 20, max 50) |
List recent chats with name, ID and unread count |
get_messages |
chat_id or number, limit (int, default 20, max 100), offset (int, default 0) |
Messages from a chat/group with pagination support. Media messages are tagged with their type and a download id |
send_message |
chat_id or number, message (required) |
Send a text message |
send_media |
chat_id or number, source (required: file path or http(s) URL), caption, as_document (bool) |
Send an image/video/audio/document |
download_media |
message_id (required) |
Download the media attached to a message; saves it under data/whatsapp_media/ and returns the path + /data/ URL |
search_messages |
query (required), max_results (int, default 20, max 50) |
Search by keyword across all chats |
search_contacts |
query (required), max_results (int, default 20, max 50) |
Search saved contacts by name (partial, case-insensitive). Use it to find the ID of a contact not present in recent chats |
chat_id format
- Contact:
39xxxxxxxxxx@c.us(international prefix without+, followed by@c.us) - Group:
xxxxxxxxxx-xxxxxxxxxx@g.us
Correct chat_ids are obtained via list_chats (recent chats) or search_contacts (saved contacts not in recent chats).
Shortcut for individual contacts: get_messages, send_message and send_media also accept a plain number (phone number with country code, e.g. 393331234567 or +39 333 123 4567) instead of a chat_id. The server resolves it via getNumberId (which also verifies the number is on WhatsApp), so there is no need to look up the chat_id first. Groups must still be addressed by chat_id.
Authentication
First time (QR scan)
On the first launch there is no saved session. The client generates a QR code:
- The LLM calls
status→ responseQR_READY - The LLM calls
get_qr→ returns the QR - The user scans the QR with WhatsApp → Settings → Linked Devices → Link a Device
- The state moves to
AUTHENTICATED, thenREADY
The QR is also saved to a file (PNG at data/whatsapp_qr.png, HTML at secrets/whatsapp_qr.html, ASCII fallback at secrets/whatsapp_qr.txt).
Subsequent sessions
The session is persisted in secrets/whatsapp_session/ (managed by whatsapp-web.js's LocalAuth). On server restart the session is restored automatically, with no need to scan the QR again.
Browser lifecycle & restart cleanup (self-healing)
The Puppeteer profile lives at secrets/whatsapp_session/session and Chromium takes an exclusive lock on it (SingletonLock): two browsers cannot open the same profile at once.
The host (skald) kills MCP servers with SIGKILL on shutdown/restart (kill_on_drop in the Rust MCP client — see crates/mcp-client/src/server.rs). SIGKILL is untrappable, so the node process dies instantly and the headless Chromium it spawned is orphaned (reparented to init), keeping the profile locked. The next launch would then fail with "The browser is already running for <profile>. Use a different userDataDir or stop the running browser first." — and every WhatsApp tool stays dead until the orphan is killed by hand.
To make this self-healing, on every startup (in initClient() → cleanupStaleBrowser()) the server:
- Runs
pgrep -f <profile-dir>andSIGKILLs every process still bound to the profile. Because skald runs a single WhatsApp MCP at a time and SIGKILLs the old node before spawning the new one, any such process is guaranteed to be a leftover orphan. - Removes the stale
SingletonLock/SingletonCookie/SingletonSocketfiles so a fresh launch is unblocked.
For the shutdown paths that are trappable (clean stdin EOF, SIGTERM/SIGINT from a container/OS stop), the server closes the browser cleanly (client.destroy(), awaited with a 5 s cap) so it releases the lock on the way out. The startup cleanup is the backstop that covers the SIGKILL path. The saved login (under session/Default/) is untouched, so no QR re-scan is needed after a restart.
Logout / expired session (without restart)
When the session expires or gets stuck (state DISCONNECTED), on restart LocalAuth would reload the invalid session from secrets/whatsapp_session/, immediately returning to the disconnected state. Previously the only fix was to delete that folder by hand and restart.
Now logout is enough:
- Attempts a clean logout (
client.logout()), tolerating failure if the browser page is already dead; - As a fallback, closes the browser (
destroy()) to release the locks on the profile; - Force-deletes
secrets/whatsapp_session/(the cached token); - Removes any stale QR files;
- Re-initializes the client → generates a new QR within a few seconds.
mcp__whatsapp__logout()
# → wait a few seconds
mcp__whatsapp__get_qr()
# → scan the new QR
No server restart required.
Token storage
| File/Directory | Contents |
|---|---|
secrets/whatsapp_session/ |
Persistent WhatsApp session (LocalAuth) — deleted by logout |
secrets/whatsapp_qr.html / data/whatsapp_qr.png |
Temporary QR code (removed after authentication or a logout) |
secrets/whatsapp_qr.txt |
ASCII fallback of the QR (when qrcode is unavailable) |
data/whatsapp_media/ |
Media downloaded via download_media, served at /data/whatsapp_media/ |
Everything under secrets/ is in .gitignore via the secrets/ rule.
Setup (one-time)
1. Install the Node.js dependencies
cd scripts/whatsapp_mcp
npm install
This installs whatsapp-web.js, puppeteer (includes Chromium, ~300MB), qrcode and qrcode-terminal.
2. Register the server (have the agent do it)
register_mcp(
name="whatsapp",
transport="stdio",
command="node",
args=["scripts/whatsapp_mcp/index.js"]
)
3. First authentication
mcp__whatsapp__status()
# → QR_READY
mcp__whatsapp__get_qr()
# → shows the QR, scan it with the phone
Usage examples
See recent chats
mcp__whatsapp__list_chats(max_chats=10)
Read the latest messages from a group
mcp__whatsapp__get_messages(
chat_id="1234567890-9876543210@g.us",
limit=50
)
Page through history (older messages)
offset skips the most recent messages, exposing the preceding window:
# Last 20 messages
get_messages(chat_id="...", limit=20, offset=0)
# Messages 21–40 (previous)
get_messages(chat_id="...", limit=20, offset=20)
# Messages 41–60 (even older)
get_messages(chat_id="...", limit=20, offset=40)
Limit: limit + offset cannot exceed 200 in a single call (a fetchMessages constraint).
Find the contact of someone not in recent chats
mcp__whatsapp__search_contacts(query="Luca")
# → Luca Rossi [contact] | ID: 393331234567@c.us
Search for what was said about a topic
mcp__whatsapp__search_messages(query="Monday meeting")
Send a message
# By chat_id (groups, or chats already open)
mcp__whatsapp__send_message(
chat_id="393331234567@c.us",
message="Hi! Are you there?"
)
# Or directly by number — no list_chats/search_contacts needed first
mcp__whatsapp__send_message(
number="+39 333 123 4567",
message="Hi! Are you there?"
)
Send media (image, video, document)
# From a local file (path relative to the project root, or absolute)
mcp__whatsapp__send_media(
number="393331234567",
source="data/report.pdf",
caption="Here is the report",
as_document=true
)
# From a URL
mcp__whatsapp__send_media(
chat_id="1234567890-9876543210@g.us",
source="https://example.com/photo.jpg",
caption="Look at this"
)
Download received media
get_messages tags media messages with a download id:
mcp__whatsapp__get_messages(number="393331234567", limit=10)
# → [2026-06-22 10:01:00] Luca [image, download id="true_39...@c.us_3EB0..."]: invoice photo
mcp__whatsapp__download_media(message_id="true_39...@c.us_3EB0...")
# → saved to data/whatsapp_media/... (also served at /data/whatsapp_media/...)
Connection states
status returns a self-describing report — it states the lifecycle state, explains it in plain language, and lists concrete next steps whenever it is not READY. The agent should not need this table; it is here for reference.
| State | Meaning | What to do |
|---|---|---|
INITIALIZING |
Browser starting up, session loading | Wait a few seconds |
QR_READY |
QR scan needed | Call get_qr and scan |
AUTHENTICATED |
QR scanned, session being established | Wait (→ READY automatically) |
READY |
Operational | All tools available |
DISCONNECTED |
Connection/session lost | Call logout to reset and log in again (no restart) |
Live socket cross-check
The lifecycle state above is driven by whatsapp-web.js events, so it can lag behind a session that drops silently. When state is READY, status also queries the live socket (client.getState(), a WAState) and reports a mismatch with tailored instructions:
Live WAState while READY |
Reported as | Fix suggested |
|---|---|---|
CONNECTED |
READY ✅ (ok) |
— |
UNPAIRED / UNPAIRED_IDLE |
action needed |
Device unlinked from phone → logout + re-scan |
CONFLICT |
action needed |
WhatsApp Web open elsewhere → close it, or logout + re-scan |
TIMEOUT |
transient |
May auto-reconnect → wait and re-check; if stuck, logout |
DEPRECATED_VERSION |
needs maintenance |
Update the whatsapp-web.js dependency (developer task) |
getState() fails / other |
uncertain |
Browser may have crashed → wait and re-check; if stuck, logout |
Enable / Disable
Disable (when not needed)
toggle_item(kind="mcp", id="whatsapp", enabled=false)
restart
Re-enable
toggle_item(kind="mcp", id="whatsapp", enabled=true)
restart
Dependencies
| Package | Version | Purpose |
|---|---|---|
whatsapp-web.js |
^1.34.7 | WhatsApp Web client |
puppeteer |
^25.1.0 | Headless Chromium (bundled) |
qrcode |
^1.5.4 | Generates the QR as PNG / data-URL (HTML) |
qrcode-terminal |
^0.12.0 | ASCII QR fallback |
System requirements:
- Node.js ≥ 18
- ~500MB of space for Puppeteer/Chromium
- A background Chromium process while the server is running
Common errors
| Error | Cause | Fix |
|---|---|---|
whatsapp-web.js not found |
npm install not run |
cd scripts/whatsapp_mcp && npm install |
WhatsApp not ready (status: INITIALIZING) |
Server just started | Wait 15-30 seconds |
WhatsApp not ready (status: QR_READY) |
Session expired/missing | Call get_qr and scan |
WhatsApp not ready (status: DISCONNECTED) |
Connection/session lost | Call logout, wait, then get_qr and scan |
Stays DISCONNECTED even after restart |
Expired cached token in secrets/whatsapp_session/ |
Call logout (clears the cache and regenerates the QR) |
The browser is already running for …/session |
An orphaned Chromium from a hard-killed (SIGKILL) prior run still holds the profile lock | Auto-healed on next startup by cleanupStaleBrowser() (kills the orphan + clears the lock). See Browser lifecycle & restart cleanup |
| Chat ID not found | Wrong ID | Use list_chats to get the correct IDs |
Protocol
Implements JSON-RPC 2.0 over stdio (same pattern as gmail and gcal):
- Requests: JSON on stdin (one per line)
- Responses: JSON on stdout
- Logs: stderr with the
[whatsapp_mcp]prefix
Supported methods: initialize, notifications/initialized, tools/list, tools/call
When to update this file
- New tools added to the server
- Changed session/QR paths under
secrets/ - New connection states
- Changed dependency versions