wa-mcp
Provides a WhatsApp MCP server and CLI for interacting with a linked WhatsApp account, including sending text and media, searching chats and messages, managing contacts and groups, downloading media, checking delivery statuses, and receiving inbound messages via webhooks.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
wa-mcp
A WhatsApp MCP server + CLI built on Baileys (multi-device protocol — no deprecated WhatsApp Web session, no phone kept online).
Text and media (image / video / audio / document) sending
Session persisted in
auth/; auto-reconnect with exponential backoff + jitterThe MCP server starts and answers every tool even when not paired (structured error with the fix)
One bad message or rejected promise never crashes the server
Every tool returns the same JSON envelope:
{ ok, data, error }
Free for developers
MIT licensed, free to use, forever. No account, no API key, no licence server, no per-message fees. Clone it, run it on your own machine, point it at your own WhatsApp account. The session and the history store stay on your disk.
What costs money is not the code, it is the running of it. Hosted multi-tenant instances, uptime and reconnect supervision, session migration for you, ban monitoring and support, or embedding wa-mcp inside a product you sell. Ask for those, do not pay for the engine.
Terms of service and account risk. WhatsApp discourages unofficial clients. This project speaks the same multi-device protocol as WhatsApp Web, so WhatsApp can restrict or close a linked account. Run it on your own account, on your own risk, and keep the official WhatsApp Business Cloud API in mind if you need templates, broadcasts, catalogue or business-profile features. This project cannot do those.
Related MCP server: MCP WhatsApp
Install
Requires Node 24+.
npm install --include=dev # --include=dev if NODE_ENV=production is set in your shell
npm run buildPair (once)
node dist/cli.js pair # prints a QR in the terminal
node dist/cli.js pair --phone +911234567890 # or: 8-character pairing code, no camera neededQR: WhatsApp → Settings → Linked devices → Link a device → scan. Code: … → Link a device → Link with phone number instead → enter the printed code.
Credentials land in auth/ (gitignored). Delete that folder to unlink locally. Check with:
node dist/cli.js statusMCP config
Use an absolute path to dist/mcp.js. Logs go to stderr only (stdout is the protocol).
Claude Code
claude mcp add whatsapp -- node C:/Users/aswat/Projects/wa-mcp/dist/mcp.jsGeneric JSON (Claude Desktop, cmdc, agy, any stdio MCP client)
{
"mcpServers": {
"whatsapp": {
"command": "node",
"args": ["C:/Users/aswat/Projects/wa-mcp/dist/mcp.js"]
}
}
}cmdc / agy: add the same
command+argsblock to the client's MCP servers config (e.g.mcp_config.json).Optional env:
WA_MCP_AUTH_DIR(session folder, default<repo>/auth),WA_MCP_LOG_LEVEL(pino level for Baileys, defaultsilent),WA_MCP_WEBHOOK_URL(inbound push target),WA_MCP_WEBHOOK_SECRET,WA_MCP_WEBHOOK_TIMEOUT_MS.
Run one process per session. If the CLI
send/syncis used while the MCP server is live, the CLI refuses (use--forceto override; it will kick the other connection off — WhatsApp allows one connection per linked device).
Shared HTTP transport (many agents, one WhatsApp session)
WhatsApp allows one live connection per linked device. Over stdio every agent spawns its own server, so only the first one holds the session and the rest fall back to read-only. The HTTP transport fixes that: one daemon owns the session and serves every agent.
agent A ──stdio──┐
agent B ──stdio──┼── bridge (dist/launch.js) ──http── daemon (dist/http.js) ── WhatsApp
agent C ──stdio──┘Point every agent at the bridge. It starts the daemon if needed and exits the daemon when the last client leaves.
{
"mcpServers": {
"whatsapp-code": {
"command": "node",
"args": ["C:/Users/aswat/Projects/wa-mcp/dist/launch.js"]
}
}
}node dist/http.js --port 8765 # daemon (usually started by the bridge)
node dist/http.js --always-on # never exit on idle
node dist/cli.js stop # stop the running daemonDaemon endpoint:
http://127.0.0.1:8765/mcp.GET /healthreturns{ ok, pid, sessions, authDir }.Bridge env:
WA_MCP_HTTP_PORT(default 8765),WA_MCP_AUTH_DIR.Daemon flags:
--port,--idle-ms(default 60000),--stale-ms(default 90000),--auth-dir,--always-on.The daemon exits after
--idle-mswith no connected clients, so closing the last agent stops it. WithWA_MCP_WEBHOOK_URLset it stays up (live inbound listener).A client that dies without a clean shutdown is dropped after
--stale-msof silence.
Point every agent at
dist/launch.js. An agent that runsdist/mcp.jsordist/codemode.jsdirectly takes the WhatsApp session itself, and the daemon then refuses to start — that is the "two agents fighting" case. The bridge says so by name when it happens.
Tool reference
All tools return { "ok": true, "data": …, "error": null } or { "ok": false, "data": null, "error": { "code", "message", "hint?" } }.
32 tools — see COMPETITORS.md for how this compares with the field.
Tool | Args | Returns ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| – |
|
|
|
|
|
| every known contact (alphabetical), optionally filtered by name/number |
|
|
|
|
|
|
|
|
|
|
|
|
|
| last message or |
|
|
|
|
| cursor read, oldest first |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| – |
|
|
|
|
|
|
|
| – |
|
| – |
|
Notes:
textis required unlessmedia_pathis given. For image/video/document,caption(ortext) is the caption. Audio can't carry a caption, so it is sent as a follow-up text;.ogg/.opusgo as voice notes.reply_toquotes a message — it must still be in the local raw cache (the last ~200 messages).toaccepts+911234567890,91 12345-67890,00911234567890,…@s.whatsapp.net,…@c.us,…@g.us(groups),…@lid.search_*,list_chats,get_chat,get_messages,get_last_interaction,get_message_contextread the local cache inauth/store.json, filled live while the server is connected (Baileys v7 has no built-in store). History from before the first link is limited to what WhatsApp syncs.download_mediaworks for messages still in the raw cache; older media needs a resend.Error codes:
NOT_PAIRED,NOT_CONNECTED,INVALID_RECIPIENT,INVALID_ARGUMENT,MEDIA_NOT_FOUND,MEDIA_TOO_LARGE(>100 MB),SEND_FAILED,INTERNAL.get_message_statusesreports the delivery lifecycle of messages you sent:sent→delivered→read→played(orerror). Statuses only move forward — a latesentnever rolls back aread.get_webhook_statusreports the inbound push sink: see Webhooks.delete_message,leave_groupandgroup_participants(remove/promote/demote) are destructive: they needconfirm: trueor they returnCONFIRMATION_REQUIREDand do nothing.get_messagesincludes astatusfield per message when a delivery receipt is known for it.
Code mode (token-lean second entry point)
The 32 tool definitions serialise to about 4k tokens of tool schema in every session that loads the main server. Code mode is a second MCP server with exactly one tool (wa_code, ~200 tokens of schema) that takes JavaScript instead of arguments, so the calling agent can filter and aggregate server-side rather than pulling 200 raw messages through its context.
node dist/codemode.js # second stdio MCP server, one tool: wa_code
node dist/cli.js code "const chats = await wa.listChats(); return chats.length;"MCP config:
{
"mcpServers": {
"whatsapp-code": {
"command": "node",
"args": ["C:/Users/aswat/Projects/wa-mcp/dist/codemode.js"]
}
}
}To run several agents against the same WhatsApp session, use dist/launch.js instead — see Shared HTTP transport.
The contract
Tool
wa_code, arguments:code(string, required) andtimeout_ms(optional string, default 5000, max 600000).The code is the body of an async function:
returnexits it and the returned value becomesdata. Returning nothing givesdata: null.Exactly one global is available —
wa. All of its functions are async:
Helper | Same as |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Destructive helpers keep the tool server's guard: the confirm argument must be exactly true, otherwise the call is refused with CONFIRMATION_REQUIRED before anything is sent.
Files: wa.files
The sandbox has no fs and no host paths, so it cannot read a file - which used to mean every media operation had to leave code mode and go through the tool server. The sandbox now has a narrow, rooted file surface instead:
Helper | What it does |
| the roots the sandbox may touch |
| read a file as text or base64 |
| write a file, creating parent directories |
| inspect and delete |
Roots default to
<authDir>/mediaand<authDir>/files. Extend them withWA_MCP_FILE_ROOT(a;-separated list of absolute paths).A path that resolves outside every root raises
FILE_FORBIDDEN, and so does a symlink that points out of a root. Reads and writes are capped at 8 MB.A media path handed to
wa.sendMessageorwa.sendStickermust be inside a root too, so a snippet cannot read any file on the machine by sending it. AsaveDirpassed towa.downloadMediais redirected into the sandbox.
That makes a full media pipeline possible inside one snippet: download, read the bytes, re-encode, write, send.
Long waits: clamped and resumable
wa.waitForMessages used to die with CODE_TIMEOUT whenever the wait outlived the snippet budget. Now:
A wait longer than the remaining budget is clamped, not fatal. The result carries
clamped: trueandbudget_exhausted: trueso the caller knows to come back.The client's 60-second ceiling is chunked internally, so a five-minute wait is five 60-second waits.
A cursor per chat makes a wait resume across calls: messages that arrived in the gap between two snippets are delivered to the next call instead of being lost, and nothing is delivered twice.
wa.waitForMessage(timeoutS?, chat?, contains?)waits for a message whose text contains a substring; non-matching messages still come back inmessages.
So the shape of a long wait is a loop over calls, and every call returns cleanly.
The result envelope is the usual
{ok, data, error}.Errors: a thrown error becomes
CODE_ERROR, a timeoutCODE_TIMEOUT, and a reference to a forbidden global (require,process,fs,globalThis, …) is refused before execution withCODE_FORBIDDEN.A host error with a known code keeps that code:
FILE_FORBIDDEN,FILE_NOT_FOUND,FILE_TOO_LARGE,NOT_PAIREDand the rest reach the caller instead of being flattened intoCODE_ERROR.The server answers even when WhatsApp is not paired:
wa.getStatus()returnsstatus: "not_paired"insidedata.
How the sandbox is built. The snippet runs in a node:vm context with string code generation disabled, so eval and the Function constructor throw EvalError inside it. The context holds no host globals; wa is installed through a closure parameter, host values cross the boundary as JSON strings and are re-parsed inside the context, and the script runs under both a VM timeout (for a synchronous infinite loop) and a wall-clock race (for an unresolved await). A bad snippet can never crash the server.
CLI
node dist/cli.js pair [--phone <number>]
node dist/cli.js status
node dist/cli.js send <number> <text> [--media <path>] [--caption <text>] [--reply-to <id>] [--force]
node dist/cli.js sync [seconds] # connect briefly to refresh the local cache
node dist/cli.js chats [limit]
node dist/cli.js messages <chat> [n]
node dist/cli.js statuses [chat] [limit]
node dist/cli.js webhook
node dist/cli.js contacts <query> [limit]
node dist/cli.js search <query> [limit]
node dist/cli.js chat <id>
node dist/cli.js last <chat>
node dist/cli.js context <chat> <message_id> [before] [after]
node dist/cli.js react <chat> <message_id> [emoji]
node dist/cli.js read <chat> [message_id]
node dist/cli.js download <chat> <message_id> [dir]
node dist/cli.js loc <to> <lat> <lng> [name] [address]
node dist/cli.js sticker <to> <path.webp>
node dist/cli.js vcard <to> <name> <phone> [email]
node dist/cli.js group <chat>
node dist/cli.js edit <chat> <message_id> <text>
node dist/cli.js del <chat> <message_id>
node dist/cli.js typing <chat> [state]
node dist/cli.js all-contacts [limit]
node dist/cli.js new [since_ts] [limit] [chat]
node dist/cli.js wait [chat] [seconds]
node dist/cli.js leave <chat>
node dist/cli.js participants <action> <chat> <phone...>
node dist/cli.js invite <chat> [--reset]
node dist/cli.js group-set <chat> [--subject <s>] [--description <d>]
node dist/cli.js create-group <subject> <phone...>node scripts/list-tools.mjs spawns the built server and prints the live tool list.
status, chats and messages don't open a connection (they read local state), so they're safe while the server runs.
Webhooks (inbound push)
Set WA_MCP_WEBHOOK_URL and the server POSTs every inbound message and every delivery/read receipt to that endpoint as JSON:
{ "type": "message", "ts": 1731200000, "data": { "id": "...", "chat": "...@s.whatsapp.net", "from": "...", "push_name": "...", "ts": 1731200000, "type": "text", "text": "...", "has_media": false } }typeismessage(inbound) orreceipt(delivery/read update).WA_MCP_WEBHOOK_SECRETadds anx-wa-mcp-signature: sha256=<hmac>header over the raw body.WA_MCP_WEBHOOK_TIMEOUT_MSsets the per-attempt timeout (default 5000).Delivery is retried twice with backoff. A dead endpoint is logged and counted, never fatal — the WhatsApp connection keeps running.
get_webhook_status(ornode dist/cli.js webhook) shows whether the sink is enabled and how many deliveries succeeded or failed.
Reliability
Reconnect: drops retry after
1s, 2s, 4s … 60s(±25 % jitter); the counter resets once connected. A post-pairing restart (515) reconnects immediately.Logged out from the phone (401): the session is cleared and status becomes
not_paired— re-runpair.Replaced (440, another process took the session): no reconnect fight; the next tool call reconnects.
Paired after the server started: the server notices on the next tool call; no restart needed.
Develop
npm run typecheck # tsc --noEmit
npm test # vitest, mocked Baileys socket — no live WhatsApp
npm run buildTool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
WhatsMCP connects Claude and other MCP-compatible AI agents directly to WhatsApp. Send and receive text, images, documents, and voice notes; manage groups (create, add/remove members, promote admins); look up contacts and profiles; follow channels; and read call and message history — all through a standard MCP interface. For voice use cases, WhatsMCP offers SIP-based calling plans (inbound-only, or full inbound/outbound) so AI voice agents can answer and place WhatsApp calls, plus low-latency WebSocket integrations with voice agent providers like ElevenLabs. Multiple WhatsApp accounts can be paired and managed per workspace, with webhook support for real-time inbound message delivery to your own infrastructure.
WhatsApp (Web + Business API), SMS, contacts, and call records via 2Chat's MCP server.
Drive WhatsApp from any MCP client: pair devices, send text and media, manage contacts and groups.
Send WhatsApp messages and templates, manage chats, contacts, broadcasts and AI agents.
Related MCP Servers
- FlicenseNot gradedqualityNot gradedmaintenanceEnables WhatsApp automation through MCP protocol, allowing users to manage sessions, send messages, handle groups/communities, and access contacts through natural language interactions with AI agents.5 npm-
- AlicenseBqualityBmaintenanceEnables sending messages, images, documents and more on WhatsApp directly from any MCP-compatible AI, with tools for chat management, groups, and webhooks.371MIT
- AlicenseNot gradedqualityCmaintenanceIntegrates WhatsApp with AI agents, enabling message sending, chat search, media sharing, approval workflows, and activity summaries via any MCP client.1Apache 2.0
- AlicenseNot gradedqualityCmaintenanceEnables programmatic WhatsApp automation through MCP, including sending messages, managing chats and contacts, searching conversation history, and exchanging media files.MIT