# 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__`; the table lists the bare `` 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: 1. The LLM calls `status` → response `QR_READY` 2. The LLM calls `get_qr` → returns the QR 3. The user scans the QR with WhatsApp → **Settings → Linked Devices → Link a Device** 4. The state moves to `AUTHENTICATED`, then `READY` 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: 1. Runs `pgrep -f ` and `SIGKILL`s 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. 2. Removes the stale `SingletonLock` / `SingletonCookie` / `SingletonSocket` files 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: 1. Attempts a clean logout (`client.logout()`), tolerating failure if the browser page is already dead; 2. As a fallback, closes the browser (`destroy()`) to release the locks on the profile; 3. **Force-deletes `secrets/whatsapp_session/`** (the cached token); 4. Removes any stale QR files; 5. 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 ```bash 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