@@ -1,5 +1,4 @@
#!/usr/bin/env node
'use strict' ;
/**
* WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition.
@@ -22,31 +21,26 @@
* `auth_state` to `ready`. WhatsApp's credential is the persisted session on disk
* (`./auth/`, under the bind-mounted home → survives a container recreate), not a
* token — so there is nothing to paste back, only a QR to scan.
*
* ── Why this file is ESM ────────────────────────────────────────────────────────
* Baileys 7.x ships ESM-only (`"type": "module"`, engines node >= 20). Importing it
* from CommonJS would rely on Node's `require(esm)` bridge, which is a moving target
* across Node versions — so the connector is ESM too, like the other node connectors.
*/
// Baileys uses the Web Crypto global (`crypto.subtle`), which Node only exposes as
// `globalThis.crypto` from v20+. The container ships Node 18 (Debian bookworm), so
// polyfill it from `node:crypto` — without this, the socket dies on connect with
// "crypto is not defined" and never reaches the QR.
const nodeCrypto = require ( 'crypto' ) ;
import fs from 'node:fs' ;
import path from 'node:path' ;
import readline from 'node:readline' ;
import nodeCrypto from 'node:crypto' ;
import { fileURLToPath } from 'node:url' ;
import qrcode from 'qrcode' ;
// Baileys uses the Web Crypto global (`crypto.subtle`). Node exposes it as
// `globalThis.crypto` from v19+, but keep the polyfill so an older runtime still
// reaches the QR instead of dying on connect with "crypto is not defined".
if ( ! globalThis . crypto ) globalThis . crypto = nodeCrypto . webcrypto ;
const fs = require ( 'fs' ) ;
const path = require ( 'path' ) ;
const readline = require ( 'readline' ) ;
const qrcode = require ( 'qrcode' ) ;
let makeWASocket , useMultiFileAuthState , DisconnectReason , fetchLatestBaileysVersion , jidNormalizedUser ;
try {
const baileys = require ( '@whiskeysockets/baileys' ) ;
makeWASocket = baileys . default || baileys . makeWASocket ;
useMultiFileAuthState = baileys . useMultiFileAuthState ;
DisconnectReason = baileys . DisconnectReason ;
fetchLatestBaileysVersion = baileys . fetchLatestBaileysVersion ;
jidNormalizedUser = baileys . jidNormalizedUser ;
} catch ( e ) {
process . stderr . write ( ` [whatsapp_mcp] FATAL: baileys not installed ( ${ e . message } ). Run npm install. \n ` ) ;
}
const _ _dirname = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
// ── Paths ──────────────────────────────────────────────────────────────────
// Everything hangs off __dirname (the connector dir inside the container home,
@@ -54,7 +48,12 @@ try {
const AUTH _DIR = path . join ( _ _dirname , 'auth' ) ; // multi-file auth state (the "session")
const MEDIA _DIR = path . join ( _ _dirname , 'media' ) ;
function log ( msg ) { process . stderr . write ( ` [whatsapp_mcp] ${ msg } \n ` ) ; }
// ── Logging ────────────────────────────────────────────────────────────────────
// Skald funnels every user's whatsapp stderr into ONE shared log file, so each line
// carries the linked account once we know it — otherwise two users' failures are
// indistinguishable after the fact.
let logTag = 'whatsapp_mcp' ;
function log ( msg ) { process . stderr . write ( ` [ ${ logTag } ] ${ msg } \n ` ) ; }
// A silent logger: Baileys requires one, and anything it prints must never reach
// stdout (that channel is reserved for JSON-RPC framing).
@@ -65,17 +64,67 @@ const silentLogger = (() => {
return l ;
} ) ( ) ;
// `libsignal` bypasses the Baileys logger and writes straight to the console: one
// "Failed to decrypt…" line plus a full stack trace *per candidate session* for every
// message it cannot open. That turned ~30 real failures into 1400+ log lines. Collapse
// the burst into a single counted line, and force any stray console.log to stderr —
// stdout belongs to the JSON-RPC framing and a dependency printing there corrupts it.
const rawConsoleError = console . error . bind ( console ) ;
let decryptFailures = 0 ;
console . error = ( ... args ) => {
const first = typeof args [ 0 ] === 'string' ? args [ 0 ] : '' ;
if ( first . startsWith ( 'Session error:' ) ) return ; // per-session stack spam
if ( first . startsWith ( 'Failed to decrypt message with any known session' ) ) {
decryptFailures ++ ;
log ( ` could not decrypt an incoming message ( ${ decryptFailures } since start) ` ) ;
return ;
}
rawConsoleError ( ... args ) ;
} ;
console . log = ( ... args ) => console . error ( ... args ) ;
console . info = console . warn = console . error ;
// ── Baileys (loaded dynamically so a missing install degrades gracefully) ───────
let makeWASocket , useMultiFileAuthState , DisconnectReason , fetchLatestBaileysVersion ,
jidNormalizedUser , WAMessageStubType ;
async function loadBaileys ( ) {
try {
const baileys = await import ( '@whiskeysockets/baileys' ) ;
makeWASocket = baileys . default || baileys . makeWASocket ;
useMultiFileAuthState = baileys . useMultiFileAuthState ;
DisconnectReason = baileys . DisconnectReason ;
fetchLatestBaileysVersion = baileys . fetchLatestBaileysVersion ;
jidNormalizedUser = baileys . jidNormalizedUser ;
WAMessageStubType = baileys . WAMessageStubType ;
return true ;
} catch ( e ) {
log ( ` FATAL: baileys not installed ( ${ e . message } ). Run npm install. ` ) ;
return false ;
}
}
// ── Connection state ─────────────────────────────────────────────────────────
// connecting – socket starting or reconnecting
// need_scan – a QR is available; the user must scan it
// ready – authenticated and connected; tools operational
// logged_out – the phone unlinked this device; a fresh QR + scan is required
let state = 'connecting' ;
let sock = null ;
let curQr = null ; // latest raw QR string (null once scanned / connected)
let meJid = null ;
let state = 'connecting' ;
let sock = null ;
let curQr = null ; // latest raw QR string (null once scanned / connected)
let meJid = null ;
let starting = false ;
// Every socket gets a generation number. Events arriving from a superseded socket are
// dropped, so a late `close` from a dead socket can never schedule a second reconnect
// or stomp on the live one's state.
let generation = 0 ;
let reconnectTimer = null ;
const RECONNECT _BASE _MS = 1500 ;
const RECONNECT _MAX _MS = 60_000 ;
let backoffMs = RECONNECT _BASE _MS ;
// ── Lightweight in-memory store ───────────────────────────────────────────────
// Baileys keeps no chat/contact store of its own; we build a minimal one from the
// history-sync event and live upserts. It lives for the process lifetime — enough
@@ -120,38 +169,104 @@ function textOf(msg) {
) ;
}
// ── WhatsApp protocol version (cached) ─────────────────────────────────────────
// `fetchLatestBaileysVersion` is a network call. Doing it on every reconnect meant
// ~15 outbound requests a day, each able to hand us a protocol version the installed
// library cannot speak. Fetch once, reuse for a few hours, fall back to the last good
// value (or the library default) when the fetch fails.
const VERSION _TTL _MS = 6 * 60 * 60 * 1000 ;
let cachedVersion = null ;
let cachedVersionAt = 0 ;
async function getWAVersion ( ) {
const now = Date . now ( ) ;
if ( cachedVersion && now - cachedVersionAt < VERSION _TTL _MS ) return cachedVersion ;
try {
const { version } = await fetchLatestBaileysVersion ( ) ;
cachedVersion = version ;
cachedVersionAt = now ;
log ( ` WhatsApp protocol version ${ version . join ( '.' ) } ` ) ;
} catch ( e ) {
log ( ` version fetch failed ( ${ e . message } ) — using ${ cachedVersion ? 'cached value' : 'library default' } ` ) ;
}
return cachedVersion || undefined ;
}
// ── WhatsApp socket lifecycle ──────────────────────────────────────────────────
// Detach and close a socket for good. Baileys keeps its own keepalive and, more
// importantly, a `creds.update → saveCreds` handler bound to the auth-state snapshot
// it was built with. Leaving an old socket alive meant two writers over the same
// `auth/` directory on every reconnect — the way Signal sessions end up inconsistent
// and messages start failing with "Bad MAC".
function teardownSock ( s ) {
if ( ! s ) return ;
try { s . ev . removeAllListeners ( ) ; } catch { /* already gone */ }
try { s . ws ? . removeAllListeners ? . ( ) ; } catch { /* already gone */ }
try { s . end ( undefined ) ; } catch { /* already closed */ }
}
function scheduleReconnect ( delayMs ) {
if ( reconnectTimer ) return ; // one pending reconnect at a time
reconnectTimer = setTimeout ( ( ) => {
reconnectTimer = null ;
startSock ( ) . catch ( ( e ) => log ( ` reconnect failed: ${ e . message } ` ) ) ;
} , delayMs ) ;
}
function disconnectName ( code ) {
const name = DisconnectReason ? . [ code ] ;
return typeof name === 'string' ? name : 'unknown' ;
}
async function startSock ( ) {
if ( starting ) return ;
if ( starting ) { scheduleReconnect ( 2000 ) ; return ; }
starting = true ;
const myGen = ++ generation ;
try {
if ( ! makeWASocket ) { state = 'connecting' ; return ; }
// Retire the previous socket BEFORE building a new one.
teardownSock ( sock ) ;
sock = null ;
fs . mkdirSync ( AUTH _DIR , { recursive : true } ) ;
const { state : authState , saveCreds } = await useMultiFileAuthState ( AUTH _DIR ) ;
let version ;
try { ( { version } = await fetchLatestBaileysVersion ( ) ) ; } catch ( _ ) { /* baileys default */ }
const version = await getWAVersion ( ) ;
sock = makeWASocket ( {
const s = makeWASocket ( {
version ,
auth : authState ,
logger : silentLogger ,
browser : [ 'Skald' , 'Chrome' , '1.0.0' ] ,
syncFullHistory : false ,
// The browser identity is not cosmetic: `getWebInfo` only asks the phone for a
// desktop-grade history sync when browser[0] is 'Mac OS' or 'Windows'. With any
// other name the sub-platform stays WEB_BROWSER and `syncFullHistory` is inert.
// Trade-off: the phone lists this device as "Mac OS Chrome", not "Skald".
browser : [ 'Mac OS' , 'Chrome' , '121.0.0' ] ,
syncFullHistory : true ,
// Set explicitly rather than left to the default. Baileys 6.7.x derives this from
// `syncFullHistory` and would otherwise gate history processing off entirely;
// 6.17.x and 7.x default it to true. Pinning it here makes the behaviour the same
// whichever version is installed.
shouldSyncHistoryMessage : ( ) => true ,
markOnlineOnConnect : false ,
generateHighQualityLinkPreview : false ,
} ) ;
sock = s ;
sock . ev . on ( 'creds.update' , saveCreds ) ;
s . ev . on ( 'creds.update' , saveCreds ) ;
sock . ev . on ( 'connection.update' , ( u ) => {
s . ev . on ( 'connection.update' , ( u ) => {
if ( myGen !== generation ) return ; // event from a superseded socket
const { connection , lastDisconnect , qr } = u ;
if ( qr ) { curQr = qr ; state = 'need_scan' ; log ( 'QR ready — awaiting scan' ) ; }
if ( connection === 'open' ) {
curQr = null ;
state = 'ready' ;
meJid = sock ? . user ? . id ? jidNormalizedUser ( sock . user . id ) : null ;
backoffMs = RECONNECT _BASE _MS ;
meJid = s . user ? . id ? jidNormalizedUser ( s . user . id ) : null ;
const who = s . user ? . name || ( meJid ? meJid . split ( '@' ) [ 0 ] : null ) ;
if ( who ) logTag = ` whatsapp_mcp ${ who } ` ;
log ( 'connection open — ready' ) ;
}
if ( connection === 'close' ) {
@@ -160,21 +275,26 @@ async function startSock() {
state = 'logged_out' ;
curQr = null ;
log ( 'logged out by phone — clearing session' ) ;
try { fs . rmSync ( AUTH _DIR , { recursive : true , force : true } ) ; } catch ( _ ) { }
// Re-init so a fresh QR is produced immediately.
teardownSock ( s ) ;
sock = null ;
try { fs . rmSync ( AUTH _DIR , { recursive : true , force : true } ) ; } catch { /* nothing to clear */ }
starting = false ;
setTimeout ( ( ) => startSock ( ) , 500 ) ;
scheduleReconnect ( 500 ) ; // produce a fresh QR immediately
} else {
state = 'connecting' ;
log ( ` connection closed (code ${ code ? ? '?' } ) — reconnecting ` ) ;
log ( ` connection closed ( ${ code ? ? '?' } ${ disconnectName ( code ) } ) — reconnecting in ${ Math . round ( backoffMs / 1000 ) } s ` ) ;
teardownSock ( s ) ;
sock = null ;
starting = false ;
setTimeout ( ( ) => startSock ( ) , 1500 ) ;
scheduleReconnect ( backoffMs ) ;
backoffMs = Math . min ( backoffMs * 2 , RECONNECT _MAX _MS ) ;
}
}
} ) ;
// Initial history sync: chats, contacts and a batch of messages.
sock . ev . on ( 'messaging-history.set' , ( { chats : hc , contacts : hcs , messages : hm } ) => {
s . ev . on ( 'messaging-history.set' , ( { chats : hc , contacts : hcs , messages : hm , syncType , progress } ) => {
if ( myGen !== generation ) return ;
for ( const c of hc || [ ] ) {
chats . set ( c . id , {
id : c . id ,
@@ -187,31 +307,40 @@ async function startSock() {
contacts . set ( c . id , { id : c . id , name : c . name || c . notify || c . verifiedName || null } ) ;
}
for ( const m of hm || [ ] ) ingestMessage ( m , false ) ;
log ( ` history sync (type ${ syncType ? ? '?' } ${ progress != null ? ` , ${ progress } % ` : '' } ): ` +
` + ${ ( hc || [ ] ) . length } chats, + ${ ( hcs || [ ] ) . length } contacts, + ${ ( hm || [ ] ) . length } messages ` +
` → ${ chats . size } chats / ${ contacts . size } contacts known ` ) ;
} ) ;
sock . ev . on ( 'chats.upsert' , ( cs ) => {
s . ev . on ( 'chats.upsert' , ( cs ) => {
if ( myGen !== generation ) return ;
for ( const c of cs ) chats . set ( c . id , {
id : c . id , name : c . name || c . subject || null ,
unread : c . unreadCount || 0 ,
conversationTimestamp : Number ( c . conversationTimestamp ) || 0 ,
} ) ;
} ) ;
sock . ev . on ( 'contacts.upsert' , ( cs ) => {
s . ev . on ( 'contacts.upsert' , ( cs ) => {
if ( myGen !== generation ) return ;
for ( const c of cs ) contacts . set ( c . id , { id : c . id , name : c . name || c . notify || c . verifiedName || null } ) ;
} ) ;
sock . ev . on ( 'contacts.update' , ( cs ) => {
s . ev . on ( 'contacts.update' , ( cs ) => {
if ( myGen !== generation ) return ;
for ( const c of cs ) {
const prev = contacts . get ( c . id ) || { id : c . id } ;
contacts . set ( c . id , { ... prev , name : c . name || c . notify || prev . name || null } ) ;
}
} ) ;
sock . ev . on ( 'messages.upsert' , ( { messages : ms , type } ) => {
s . ev . on ( 'messages.upsert' , ( { messages : ms , type } ) => {
if ( myGen !== generation ) return ;
for ( const m of ms ) ingestMessage ( m , type === 'notify' ) ;
} ) ;
} catch ( e ) {
log ( ` startSock error: ${ e . message } ` ) ;
state = 'connecting' ;
scheduleReconnect ( backoffMs ) ;
backoffMs = Math . min ( backoffMs * 2 , RECONNECT _MAX _MS ) ;
} finally {
starting = false ;
}
@@ -221,7 +350,19 @@ function ingestMessage(m, live) {
try {
const jid = m . key ? . remoteJid ;
if ( ! jid || jid === 'status@broadcast' ) return ;
const text = textOf ( m ) ;
// A message Signal could not open arrives with no `message` payload and the
// CIPHERTEXT stub type. It used to be stored with an empty text, so `get_messages`
// rendered it as a blank line and the agent had no way to tell a silent gap from a
// genuinely empty message. Keep it, but say what it is.
const undecryptable = WAMessageStubType != null &&
m . messageStubType === WAMessageStubType . CIPHERTEXT ;
const text = undecryptable ? '[undecryptable message]' : textOf ( m ) ;
// Protocol/system frames (reactions, receipts, key distribution…) carry no text
// and are pure noise in a transcript.
if ( ! text ) return ;
pushMessage ( jid , {
id : m . key ? . id ,
fromMe : ! ! m . key ? . fromMe ,
@@ -236,7 +377,7 @@ function ingestMessage(m, live) {
ch . conversationTimestamp = Number ( m . messageTimestamp ) || ch . conversationTimestamp ;
if ( m . pushName && ! ch . name ) ch . name = m . pushName ;
}
} catch ( _ ) { }
} catch { /* one malformed frame must not stop the stream */ }
}
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -263,7 +404,7 @@ function requireReady() {
async function toolLoginStatus ( ) {
let qrDataUrl = null ;
if ( state === 'need_scan' && curQr ) {
try { qrDataUrl = await qrcode . toDataURL ( curQr , { width : 320 , margin : 2 } ) ; } catch ( _ ) { }
try { qrDataUrl = await qrcode . toDataURL ( curQr , { width : 320 , margin : 2 } ) ; } catch { /* no QR to render */ }
}
const message = {
connecting : 'Connecting to WhatsApp…' ,
@@ -278,17 +419,25 @@ async function toolLoginStatus() {
async function toolStatus ( ) {
const s = await toolLoginStatus ( ) ;
const { state : st , message } = JSON . parse ( s ) ;
const chatCount = chats . size ;
return ` WhatsApp status: ${ st . toUpperCase ( ) } \n ${ message } ` +
( st === 'ready' ? ` \n Known chats: ${ chatCount } ` : '' ) ;
( st === 'ready'
? ` \n Known chats: ${ chats . size } ` +
` \n Known contacts: ${ contacts . size } ` +
( decryptFailures ? ` \n Undecryptable messages since start: ${ decryptFailures } ` : '' )
: '' ) ;
}
async function toolLogout ( ) {
try { if ( sock ) await sock . logout ( ) ; } catch ( _ ) { }
try { fs . rmSync ( AUTH _DIR , { recursive : true , force : true } ) ; } catch ( _ ) { }
try { if ( sock ) await sock . logout ( ) ; } catch { /* already gone */ }
generation ++ ; // orphan any in-flight socket events
teardownSock ( sock ) ;
sock = null ;
if ( reconnectTimer ) { clearTimeout ( reconnectTimer ) ; reconnectTimer = null ; }
try { fs . rmSync ( AUTH _DIR , { recursive : true , force : true } ) ; } catch { /* nothing to clear */ }
chats . clear ( ) ; contacts . clear ( ) ; messages . clear ( ) ;
curQr = null ; state = 'connecting' ; starting = false ; meJid = null ;
setTimeout ( ( ) => startSock ( ) , 500 ) ;
backoffMs = RECONNECT _BASE _MS ;
scheduleReconnect ( 500 ) ;
return 'Logged out and cleared the session. A new QR code will be generated — open the connector in Skald and scan it.' ;
}
@@ -447,7 +596,7 @@ async function handleRequest(msg) {
return okResponse ( id , {
protocolVersion : '2024-11-05' ,
capabilities : { tools : { } } ,
serverInfo : { name : 'whatsapp' , version : '2.0 .0' } ,
serverInfo : { name : 'whatsapp' , version : '2.1 .0' } ,
} ) ;
}
if ( method === 'notifications/initialized' ) return null ;
@@ -485,7 +634,10 @@ async function handleRequest(msg) {
async function main ( ) {
log ( 'Starting WhatsApp MCP server (Baileys)' ) ;
fs . mkdirSync ( MEDIA _DIR , { recursive : true } ) ;
startSock ( ) . catch ( ( e ) => log ( ` initial startSock failed: ${ e . message } ` ) ) ;
if ( await loadBaileys ( ) ) {
startSock ( ) . catch ( ( e ) => log ( ` initial startSock failed: ${ e . message } ` ) ) ;
}
const rl = readline . createInterface ( { input : process . stdin , crlfDelay : Infinity } ) ;
rl . on ( 'line' , async ( line ) => {
@@ -496,10 +648,17 @@ async function main() {
const resp = await handleRequest ( msg ) ;
if ( resp !== null ) process . stdout . write ( resp + '\n' ) ;
} ) ;
rl . on ( 'close' , ( ) => { log ( 'stdin closed, shutting down' ) ; process . exit ( 0 ) ; } ) ;
rl . on ( 'close' , ( ) => { log ( 'stdin closed, shutting down' ) ; shutdown ( 0 ) ; } ) ;
process . on ( 'SIGTERM' , ( ) => { log ( 'SIGTERM' ) ; process . exit ( 0 ) ; } ) ;
process . on ( 'SIGINT' , ( ) => { log ( 'SIGINT' ) ; process . exit ( 0 ) ; } ) ;
process . on ( 'SIGTERM' , ( ) => { log ( 'SIGTERM' ) ; shutdown ( 0 ) ; } ) ;
process . on ( 'SIGINT' , ( ) => { log ( 'SIGINT' ) ; shutdown ( 0 ) ; } ) ;
}
function shutdown ( code ) {
generation ++ ;
teardownSock ( sock ) ;
sock = null ;
process . exit ( code ) ;
}
main ( ) . catch ( ( e ) => { log ( ` Fatal: ${ e . message } ` ) ; process . exit ( 1 ) ; } ) ;