Files
Skald-Circle/docs/mcp/servers/whatsapp.md
T
2026-07-10 15:02:09 +01:00

350 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
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 &lt;profile&gt;. 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 <profile-dir>` 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 2140 (previous)
get_messages(chat_id="...", limit=20, offset=20)
# Messages 4160 (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