#!/usr/bin/env node /** * WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition. * * Runs INSIDE the user's per-user container (blueprint §6/§7). Unlike the old * whatsapp-web.js server, this one uses `@whiskeysockets/baileys`: a pure-WebSocket * WhatsApp multi-device client with **no browser** — so it fits the slim * `skald-runtime` image (node, no Chromium) and needs no puppeteer self-healing. * * ── Interactive login contract (the generic §15 seam) ─────────────────────────── * A per-user connector that needs an interactive login exposes ONE standard tool, * `login_status`, that Skald's login API calls directly (never the agent). It * returns a small JSON object the login panel renders: * * { "state": "connecting" | "need_scan" | "ready" | "logged_out", * "qr": "data:image/png;base64,…" // present only while state == need_scan * "message": "human-readable line" } * * The panel polls it; when `state == "ready"` Skald flips the connector's * `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. */ 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 __dirname = path.dirname(fileURLToPath(import.meta.url)); // ── Paths ────────────────────────────────────────────────────────────────── // Everything hangs off __dirname (the connector dir inside the container home, // `~/.skald/mcp//`), which is bind-mounted and therefore durable. const AUTH_DIR = path.join(__dirname, 'auth'); // multi-file auth state (the "session") const MEDIA_DIR = path.join(__dirname, 'media'); // ── 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). const silentLogger = (() => { const noop = () => {}; const l = { level: 'silent', trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop }; l.child = () => l; 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 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; // ── Store ───────────────────────────────────────────────────────────────────── // Baileys keeps no chat/contact store of its own; we build a minimal one from the // history-sync event and live upserts. It is mirrored to `store/` (bind-mounted next // to `auth/`, so it survives a container recreate) because WhatsApp only delivers a // history sync at *login* — without a mirror every process restart left the connector // blind until new messages happened to arrive. // // Deliberately not SQLite: `node:sqlite` needs Node 22 (the runtime image is 20.19.2) // and `better-sqlite3` is a native module the slim image cannot build. So: an // append-only JSONL log for messages (O(1) per message) plus a small debounced // snapshot for chats/contacts. const chats = new Map(); // jid -> { id, name, unread, conversationTimestamp } const contacts = new Map(); // jid -> { id, name } const messages = new Map(); // jid -> [ { id, fromMe, ts, text, author } ] (capped) const MAX_MSGS_PER_CHAT = 500; const STORE_DIR = path.join(__dirname, 'store'); const MSG_LOG = path.join(STORE_DIR, 'messages.jsonl'); const META_FILE = path.join(STORE_DIR, 'meta.json'); const COMPACT_EVERY = 500; // appends between rewrites of the log const META_DEBOUNCE_MS = 5000; let appendsSinceCompact = 0; let metaTimer = null; // Returns false when the message was already known: history sync re-delivers the same // ids on every login, and without this both the log and the transcript would grow a // duplicate per restart. function pushMessage(jid, m) { if (!jid) return false; let arr = messages.get(jid); if (!arr) { arr = []; messages.set(jid, arr); } if (m.id && arr.some((x) => x.id === m.id)) return false; arr.push(m); if (arr.length > MAX_MSGS_PER_CHAT) arr.splice(0, arr.length - MAX_MSGS_PER_CHAT); return true; } function writeFileAtomic(file, data) { const tmp = `${file}.tmp`; fs.writeFileSync(tmp, data); fs.renameSync(tmp, file); // rename is atomic: a crash mid-write cannot truncate the store } function loadStore() { try { const meta = JSON.parse(fs.readFileSync(META_FILE, 'utf8')); for (const c of meta.chats || []) if (c?.id) chats.set(c.id, c); for (const c of meta.contacts || []) if (c?.id) contacts.set(c.id, c); } catch { /* first run, or a snapshot we cannot parse — start empty */ } let restored = 0; try { for (const line of fs.readFileSync(MSG_LOG, 'utf8').split('\n')) { if (!line) continue; try { const { jid, m } = JSON.parse(line); if (pushMessage(jid, m)) restored++; } catch { /* skip a torn trailing line */ } } } catch { /* no log yet */ } if (restored || chats.size || contacts.size) { log(`store loaded: ${chats.size} chats, ${contacts.size} contacts, ${restored} messages`); } // Rewrite immediately: replays the per-chat cap and drops duplicates, so the log // cannot creep upward across restarts. if (restored) compactMessageLog(); } function compactMessageLog() { try { fs.mkdirSync(STORE_DIR, { recursive: true }); const out = []; for (const [jid, arr] of messages) for (const m of arr) out.push(JSON.stringify({ jid, m })); writeFileAtomic(MSG_LOG, out.length ? out.join('\n') + '\n' : ''); appendsSinceCompact = 0; } catch (e) { log(`store compaction failed: ${e.message}`); } } function appendMessage(jid, m) { try { fs.mkdirSync(STORE_DIR, { recursive: true }); fs.appendFileSync(MSG_LOG, JSON.stringify({ jid, m }) + '\n'); if (++appendsSinceCompact >= COMPACT_EVERY) compactMessageLog(); } catch (e) { log(`store append failed: ${e.message}`); } } function saveMeta() { try { fs.mkdirSync(STORE_DIR, { recursive: true }); writeFileAtomic(META_FILE, JSON.stringify({ chats: [...chats.values()], contacts: [...contacts.values()], })); } catch (e) { log(`store meta save failed: ${e.message}`); } } // chats/contacts churn in bursts during a history sync; one write per burst is enough. function scheduleMetaSave() { if (metaTimer) return; metaTimer = setTimeout(() => { metaTimer = null; saveMeta(); }, META_DEBOUNCE_MS); } function flushStore() { if (metaTimer) { clearTimeout(metaTimer); metaTimer = null; } saveMeta(); } function clearStore() { try { fs.rmSync(STORE_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ } appendsSinceCompact = 0; if (metaTimer) { clearTimeout(metaTimer); metaTimer = null; } } function contactName(jid) { const c = contacts.get(jid); if (c && c.name) return c.name; const ch = chats.get(jid); if (ch && ch.name) return ch.name; return jid ? jid.split('@')[0] : 'unknown'; } function textOf(msg) { const m = msg.message; if (!m) return ''; return ( m.conversation || m.extendedTextMessage?.text || m.imageMessage?.caption || m.videoMessage?.caption || m.documentMessage?.caption || (m.imageMessage ? '[image]' : '') || (m.videoMessage ? '[video]' : '') || (m.audioMessage ? '[audio]' : '') || (m.documentMessage ? '[document]' : '') || (m.stickerMessage ? '[sticker]' : '') || '' ); } // ── 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) { 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); const version = await getWAVersion(); const s = makeWASocket({ version, auth: authState, logger: silentLogger, // 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; s.ev.on('creds.update', saveCreds); 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'; 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') { const code = lastDisconnect?.error?.output?.statusCode; if (code === DisconnectReason.loggedOut) { state = 'logged_out'; curQr = null; log('logged out by phone — clearing session'); teardownSock(s); sock = null; try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ } starting = false; scheduleReconnect(500); // produce a fresh QR immediately } else { state = 'connecting'; log(`connection closed (${code ?? '?'} ${disconnectName(code)}) — reconnecting in ${Math.round(backoffMs / 1000)}s`); teardownSock(s); sock = null; starting = false; scheduleReconnect(backoffMs); backoffMs = Math.min(backoffMs * 2, RECONNECT_MAX_MS); } } }); // Initial history sync: chats, contacts and a batch of messages. 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, name: c.name || c.subject || null, unread: c.unreadCount || 0, conversationTimestamp: Number(c.conversationTimestamp) || 0, }); } for (const c of hcs || []) { contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null }); } for (const m of hm || []) ingestMessage(m, false); scheduleMetaSave(); log(`history sync (type ${syncType ?? '?'}${progress != null ? `, ${progress}%` : ''}): ` + `+${(hc || []).length} chats, +${(hcs || []).length} contacts, +${(hm || []).length} messages ` + `→ ${chats.size} chats / ${contacts.size} contacts known`); }); 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, }); scheduleMetaSave(); }); 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 }); scheduleMetaSave(); }); 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 }); } scheduleMetaSave(); }); 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; } } function ingestMessage(m, live) { try { const jid = m.key?.remoteJid; if (!jid || jid === 'status@broadcast') return; // 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; const stored = { id: m.key?.id, fromMe: !!m.key?.fromMe, ts: Number(m.messageTimestamp) || 0, text, author: m.key?.participant || (m.key?.fromMe ? meJid : jid), }; if (!pushMessage(jid, stored)) return; // already known — do not log it twice appendMessage(jid, stored); if (live && !chats.has(jid)) { chats.set(jid, { id: jid, name: m.pushName || null, unread: 0, conversationTimestamp: Number(m.messageTimestamp) || 0 }); scheduleMetaSave(); } else if (live) { const ch = chats.get(jid); ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp; if (m.pushName && !ch.name) ch.name = m.pushName; scheduleMetaSave(); } } catch { /* one malformed frame must not stop the stream */ } } // ── Helpers ──────────────────────────────────────────────────────────────────── // Turn a plain phone number or a chat id into a WhatsApp jid. function toJid(chat_id, number) { if (chat_id && chat_id.includes('@')) return chat_id; const raw = (chat_id || number || '').replace(/[^0-9]/g, ''); if (!raw) return null; return `${raw}@s.whatsapp.net`; } function requireReady() { if (state !== 'ready') { throw new Error(`WhatsApp is not connected (state: ${state}). ` + (state === 'need_scan' || state === 'logged_out' ? 'Open the connector in Skald and scan the QR code to sign in.' : 'It is still connecting — try again in a few seconds.')); } } // ── Tools: interactive login (the §15 generic contract) ───────────────────────── async function toolLoginStatus() { let qrDataUrl = null; if (state === 'need_scan' && curQr) { try { qrDataUrl = await qrcode.toDataURL(curQr, { width: 320, margin: 2 }); } catch { /* no QR to render */ } } const message = { connecting: 'Connecting to WhatsApp…', need_scan: 'Scan this QR code: WhatsApp → Settings → Linked Devices → Link a Device.', ready: 'WhatsApp is connected.', logged_out: 'This device was unlinked. Scan the new QR code to sign in again.', }[state] || state; // Returned as a JSON string in a text content part; the login API parses it. return JSON.stringify({ state, qr: qrDataUrl, message }); } async function toolStatus() { const s = await toolLoginStatus(); const { state: st, message } = JSON.parse(s); return `WhatsApp status: ${st.toUpperCase()}\n${message}` + (st === 'ready' ? `\nKnown chats: ${chats.size}` + `\nKnown contacts: ${contacts.size}` + (decryptFailures ? `\nUndecryptable messages since start: ${decryptFailures}` : '') : ''); } async function toolLogout() { 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(); clearStore(); curQr = null; state = 'connecting'; starting = false; meJid = null; 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.'; } // ── Tools: messaging ──────────────────────────────────────────────────────────── async function toolListChats(args) { requireReady(); const max = Math.min(Math.max(1, args.max_chats || 20), 50); const list = [...chats.values()] .sort((a, b) => (b.conversationTimestamp || 0) - (a.conversationTimestamp || 0)) .slice(0, max); if (!list.length) return 'No chats known yet. History may still be syncing — try again in a few seconds.'; const lines = [`Recent WhatsApp chats (${list.length}):`]; for (const c of list) { const kind = c.id.endsWith('@g.us') ? '[group]' : '[chat]'; const unread = c.unread ? ` (${c.unread} unread)` : ''; lines.push(`- ${c.name || contactName(c.id)} ${kind}${unread} | ID: ${c.id}`); } return lines.join('\n'); } async function toolGetMessages(args) { requireReady(); const jid = toJid(args.chat_id, args.number); if (!jid) return 'Error: provide chat_id or number.'; const limit = Math.min(Math.max(1, args.limit || 20), 100); const offset = Math.max(0, args.offset || 0); const arr = (messages.get(jid) || []).slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)); if (!arr.length) return `No messages buffered for ${contactName(jid)} (${jid}). Only messages seen since sign-in are available.`; const end = arr.length - offset; const slice = arr.slice(Math.max(0, end - limit), Math.max(0, end)); const lines = [`Messages with ${contactName(jid)} (${jid}):`]; for (const m of slice) { const who = m.fromMe ? 'me' : (jid.endsWith('@g.us') ? contactName(m.author) : contactName(jid)); const when = m.ts ? new Date(m.ts * 1000).toISOString().replace('T', ' ').slice(0, 16) : ''; lines.push(`[${when}] ${who}: ${m.text}`); } return lines.join('\n'); } async function toolSendMessage(args) { requireReady(); const jid = toJid(args.chat_id, args.number); if (!jid) return 'Error: provide chat_id or number.'; if (!args.message) return 'Error: message is required.'; await sock.sendMessage(jid, { text: String(args.message) }); return `Message sent to ${contactName(jid)} (${jid}).`; } async function toolSearchContacts(args) { requireReady(); const q = String(args.query || '').toLowerCase(); if (!q) return 'Error: query is required.'; const max = Math.min(Math.max(1, args.max_results || 20), 50); const seen = new Set(); const out = []; for (const c of contacts.values()) { if (out.length >= max) break; const name = c.name || ''; if (name.toLowerCase().includes(q) || c.id.includes(q)) { if (seen.has(c.id)) continue; seen.add(c.id); out.push(`- ${name || contactName(c.id)} | ID: ${c.id}`); } } if (!out.length) return `No contacts found matching "${args.query}".`; return [`Contacts matching "${args.query}" (${out.length}):`, ...out].join('\n'); } // ── MCP tool definitions ──────────────────────────────────────────────────────── const TOOLS = [ { name: 'login_status', title: 'Login Status', description: 'Interactive-login status for this connector (used by the Skald login panel). Returns a JSON object {state, qr, message}: state is connecting|need_scan|ready|logged_out; qr is a data-URL PNG present only while a scan is needed. Safe to poll.', inputSchema: { type: 'object', properties: {} }, }, { name: 'status', title: 'Status', description: 'WhatsApp connection status as a short human-readable report. Call this first when another WhatsApp tool fails.', inputSchema: { type: 'object', properties: {} }, }, { name: 'logout', title: 'Logout', description: 'Log out of WhatsApp: end the session, clear the stored credentials, and generate a fresh QR code to link a (possibly different) phone. After calling, the user must scan the new QR in the Skald connector page.', inputSchema: { type: 'object', properties: {} }, }, { name: 'list_chats', title: 'List Chats', description: 'List recent WhatsApp chats (contacts and groups) with name, ID and unread count. Only chats seen since sign-in / history sync are known.', inputSchema: { type: 'object', properties: { max_chats: { type: 'integer', description: 'Max chats to return (default 20, max 50).' } }, }, }, { name: 'get_messages', title: 'Get Messages', description: 'Get buffered messages from a chat. Identify it with EITHER chat_id (from list_chats) OR a phone number with country code for an individual contact. Only messages seen since sign-in are available (no deep history).', inputSchema: { type: 'object', properties: { chat_id: { type: 'string', description: 'Chat ID, e.g. "39XXXXXXXXXX@s.whatsapp.net" or "…@g.us".' }, number: { type: 'string', description: 'Alternative to chat_id: phone number with country code (e.g. "393331234567"). Ignored if chat_id is given.' }, limit: { type: 'integer', description: 'Number of messages (default 20, max 100).' }, offset: { type: 'integer', description: 'Skip this many of the most recent messages (default 0).' }, }, }, }, { name: 'send_message', title: 'Send Message', description: 'Send a WhatsApp text message. Identify the recipient with EITHER chat_id (from list_chats, use for groups) OR a phone number with country code for an individual contact.', inputSchema: { type: 'object', properties: { chat_id: { type: 'string', description: 'Chat ID to send to (use for groups).' }, number: { type: 'string', description: 'Alternative to chat_id: phone number with country code. Ignored if chat_id is given.' }, message: { type: 'string', description: 'The text to send.' }, }, required: ['message'], }, }, { name: 'search_contacts', title: 'Search Contacts', description: 'Search known WhatsApp contacts by name or number. Use to find a contact ID to message.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Name or partial name/number (case-insensitive).' }, max_results: { type: 'integer', description: 'Max contacts to return (default 20, max 50).' }, }, required: ['query'], }, }, ]; // ── JSON-RPC framing ───────────────────────────────────────────────────────── function okResponse(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); } function textResult(id, text, isError = false) { const result = { content: [{ type: 'text', text }] }; if (isError) result.isError = true; return JSON.stringify({ jsonrpc: '2.0', id, result }); } async function handleRequest(msg) { const { method, id, params } = msg; if (method === 'initialize') { return okResponse(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'whatsapp', version: '2.1.0' }, }); } if (method === 'notifications/initialized') return null; if (method === 'tools/list') return okResponse(id, { tools: TOOLS }); if (method === 'tools/call') { const toolName = params?.name || ''; const toolArgs = params?.arguments || {}; let text; try { switch (toolName) { case 'login_status': text = await toolLoginStatus(); break; case 'status': text = await toolStatus(); break; case 'logout': text = await toolLogout(); break; case 'list_chats': text = await toolListChats(toolArgs); break; case 'get_messages': text = await toolGetMessages(toolArgs); break; case 'send_message': text = await toolSendMessage(toolArgs); break; case 'search_contacts': text = await toolSearchContacts(toolArgs); break; default: return textResult(id, `Unknown tool: ${toolName}`, true); } } catch (e) { log(`tool '${toolName}' error: ${e.message}`); return textResult(id, `Error: ${e.message}`, true); } const isErr = typeof text === 'string' && text.startsWith('Error:'); return textResult(id, text, isErr); } return JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { log('Starting WhatsApp MCP server (Baileys)'); fs.mkdirSync(MEDIA_DIR, { recursive: true }); loadStore(); 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) => { line = line.trim(); if (!line) return; let msg; try { msg = JSON.parse(line); } catch (e) { log(`bad JSON on stdin: ${e.message}`); return; } const resp = await handleRequest(msg); if (resp !== null) process.stdout.write(resp + '\n'); }); rl.on('close', () => { log('stdin closed, shutting down'); shutdown(0); }); process.on('SIGTERM', () => { log('SIGTERM'); shutdown(0); }); process.on('SIGINT', () => { log('SIGINT'); shutdown(0); }); } function shutdown(code) { generation++; teardownSock(sock); sock = null; flushStore(); process.exit(code); } main().catch((e) => { log(`Fatal: ${e.message}`); process.exit(1); });