원문 캡처 · github.com
원문 캡처 · github.com

whatsapp-mcp는 AI 에이전트가 WhatsApp 메시지를 읽도록 하는 소형 서버다. 사용자가 자신의 Cloudflare 계정에 배포하고 휴대전화를 한 번 연결하면 MCP를 지원하는 클라이언트가 채팅을 검색하고 읽을 수 있다. 읽기 전용이며 메시지를 보낼 수는 없다.

에이전트에게 다음과 같이 물을 수 있다.

  • “Dana가 항공편에 관해 뭐라고 했지?”
  • “모든 채팅에서 임대 계약을 언급한 메시지를 찾아줘.”
  • “이번 주 가족 그룹에서 놓친 내용을 요약해줘.”

작동 방식과 설정

사용자의 휴대전화가 데이터의 원본이다. 서버는 에이전트가 연결하는 연동 기기로 작동한다. WhatsApp에 계속 연결된 상태로 있지는 않는다. 에이전트가 요청할 때와 하루 한 번 서버가 연결해, 마지막 연결 이후 WhatsApp이 전달하려고 대기시킨 내용을 가져와 자체 데이터베이스에 저장한 뒤 연결을 끊는다. 휴대전화는 평소처럼 작동하며 알림도 계속 받는다.

메시지는 사용자의 Cloudflare 계정에 있는 Durable Object 내부 SQLite 데이터베이스에 저장된다. 다른 곳으로 전송되지 않는다.

설정에는 Cloudflare Workers 유료 플랜 계정(아래 설명 참조), Node 20 이상, 휴대전화가 필요하다.

text
git clone https://github.com/finereli/whatsapp-mcp
cd whatsapp-mcp
npm install

# 1. 로그인 토큰을 저장할 공간을 만든다. 출력된 ID를 wrangler.jsonc에 붙여 넣는다.
npx wrangler kv namespace create OAUTH_KV

# 2. 휴대전화를 연결하는 페이지의 비밀번호를 설정한다. 12자 이상이어야 한다.
npx wrangler secret put OWNER_PASSWORD

# 3. 배포한다.
npm run deploy

Wrangler가 https://whatsapp-mcp.your-name.workers.dev와 같은 URL을 출력한다. 해당 주소를 열면 남은 두 단계를 안내하는 첫 화면이 나온다.

  • WhatsApp을 연결한다. WhatsApp에서 설정, 연결된 기기, 기기 연결을 차례로 선택한 뒤 QR 코드를 스캔한다. 휴대전화에서 직접 접속해 화면을 스캔할 수 없다면 전화번호 옵션을 쓴다. 첫 연결 때 채팅 기록을 가져오며, 페이지에 진행 상황이 표시된다. 가져오기가 진행되는 동안 페이지를 닫아도 된다.
  • 에이전트를 연결한다. Claude Code에서는 claude mcp add --transport http whatsapp https://your-url/mcp를 실행한 다음 /mcp를 입력하고 비밀번호로 로그인한다. 다른 클라이언트에서는 같은 /mcp URL을 지정한다.

자체 도메인을 쓰거나 비밀번호 대신 Google 로그인을 사용하려면 wrangler.jsonc 맨 아래의 주석을 참고한다.

Workers 유료 플랜이 필요한 이유

대량의 기록을 가져올 때는 많은 데이터를 한꺼번에 디코딩한다. Workers의 기본 CPU 한도로는 이 작업을 처리하기 어렵다. wrangler.jsonc에서 CPU 한도를 높이며, 이 설정에는 한 달에 몇 달러가 드는 유료 플랜이 필요하다. 제작자는 무료 플랜에서 시험하지 않았다. 무료 플랜을 사용한다면 limits 블록을 제거해야 하며, 첫 기록 가져오기가 불안정할 수 있다.

알아둘 점과 사용할 수 있는 기능

이 프로젝트는 공식 제품이 아니다. WhatsApp은 개인 메시지를 읽는 API를 제공하지 않는다. 이 프로젝트는 오픈소스 라이브러리 Baileys를 통해 WhatsApp Web과 같은 프로토콜을 사용하며, 이는 WhatsApp의 이용약관에 어긋난다. 읽기만 하는 연동 기기의 실질적 위험은 낮지만 0은 아니다. 자신의 계정에서만 사용하고 메시지 전송 용도로 쓰지 말아야 한다.

가져올 수 있는 기록은 WhatsApp이 새 기기에 전달하는 내용에 달려 있다. 첫 연결 때 기록이 모두 전달되지 않을 수 있으며, 기록 가져오기는 연결할 때 한 번만 이뤄진다. 다시 가져오려면 기기를 재연결해야 한다. 기존에 저장한 메시지는 유지되며, 재가져오기가 빠진 내용을 채운다.

연락처 이름이 표시되지 않는 채팅은 전화번호로 나타날 수 있다. WhatsApp은 연동 기기에 모든 연락처 이름을 제공하지 않는다. 이름을 알 수 없는 상대와의 채팅은 번호로 표시된다. 주소록을 가져와 이름을 채우는 기능은 자연스러운 다음 단계지만 아직 구현되지 않았다.

사진, 음성 메시지, 문서 같은 미디어 파일은 내려받지 않는다. 대신 [image]나 [voice note 12s] 같은 표시와 원래 캡션이 나타난다. 음성 메시지를 텍스트로 변환하지도 않는다.

배포 하나는 WhatsApp 계정 하나와 소유자 한 명만 지원한다. 각 배포에 전체 메시지 기록과 계정의 활성 로그인이 저장되므로, 각 사용자가 따로 배포하도록 의도적으로 설계했다.

휴대전화에서 기기가 제거됐다고 표시되거나 처음부터 다시 시작하고 싶다면 에이전트에게 relink를 실행하게 하거나 /setup을 열어 “Unlink and scan again”을 선택한다. 새 QR 코드를 스캔해야 하며, 저장된 메시지는 유지된다.

MCP 클라이언트가 사용할 수 있는 도구는 다음과 같다.

도구기능
list_chats최신순으로 채팅을 나열하고 이름이나 번호로 필터링한다.
get_messages한 채팅의 메시지를 읽고 날짜 기준으로 앞뒤 페이지를 넘긴다.
search_messages전체 메시지 또는 특정 채팅 안에서 전문 검색한다.
sync_now새 메시지를 즉시 가져온다.
link_status연결 여부, 마지막으로 메시지를 가져온 시점, 저장된 데이터의 양을 확인한다.
relink연결을 해제하고 다시 시작한다. 새 QR 코드는 사람이 스캔해야 한다.

저장된 메시지가 10분보다 오래된 경우, 읽기 도구는 실행 전에 WhatsApp에서 최신 내용을 가져온다.

코드 구조와 데이터 처리

다음은 포크에서 코드를 수정하는 사람이나 AI 에이전트를 위한 설명이다. 앞부분은 배포를 소유한 사람을 대상으로 한다.

text
MCP client ──OAuth──> Worker (src/index.ts)                         ├─ /mcp ───────────> mcp.ts: tools, one fresh McpServer per request                         ├─ /, /setup, /authorize, /login/*, /pair/*   (landing, sign-in, pairing UI)                         └─ cron 04:00 UTC ──> pull()                                    │ RPC                                    v               WhatsAppStore (Durable Object, one instance named "owner")                 ├─ SQLite: auth keys, messages, chats, contacts, FTS5 index                 └─ Baileys socket, opened only during a session (pairing or pull)                                    │ WebSocket                                    v                               WhatsApp servers

모든 데이터는 Durable Object 하나에 들어간다. 그 안의 SQLite는 Baileys 인증 정보 저장소이자 메시지 저장소다. Worker는 상태를 저장하지 않는다. 예외는 OAuth 공급자가 토큰을 저장하는 KV 네임스페이스와 수명이 짧은 “pending sign-in” 기록이다.

파일역할
src/index.ts라우트, OAuth 공급자 연결, 로그인, 기기 연결 엔드포인트, /debug, 예약 작업을 담당한다.
src/auth.ts소유자를 판별한다. 기본값은 비밀번호 방식이며 선택적으로 로그인 서비스를 쓸 수 있다. 다른 인증 제공자로 바꾸려면 이 파일을 수정한다.
src/whatsapp-do.tsDurable Object다. Baileys 세션을 실행하고 Worker에 RPC 메서드를 제공한다.
src/store.ts스키마와 Baileys 이벤트의 SQLite 저장, 모든 쿼리를 담당한다.
src/auth-state.tsDurable Object의 SQLite를 사용하는 Baileys 인증 상태다. useMultiFileAuthState를 대체한다.
src/mcp.tsMCP 도구를 정의한다.
src/pages.ts, src/landing.ts, src/ui.tsHTML을 담당한다. ui.ts에는 공통 디자인 토큰이 있다.
src/ws-shim.ts, src/lru-shim.ts, src/polyfills.tsBaileys가 workerd에서 실행되도록 한다.
scripts/vendor-bridge.mjsBaileys의 Rust 브리지에서 wasm을 추출한다. npm install 때 실행된다.
local-export/서버 없이 기록을 JSON 파일로 내보내는 별도의 일회성 스크립트다. 배포 구성에는 포함되지 않는다.

WhatsApp 통신은 모두 whatsapp-do.ts의 runSession()에서 이뤄진다. 세션은 두 종류다.

  • 연결(pairing)은 아직 인증 정보가 없을 때 시작한다. Baileys가 만든 QR 문자열은 메모리에 보관하고 pairStatus()가 SVG로 표시한다. QR 코드를 스캔하면 WhatsApp이 코드 515(재시작 필요)로 소켓을 닫고, 새 인증 정보로 세션을 다시 연결한다. 기록은 messaging-history.set 이벤트 청크로 도착하며 진행률도 함께 전달된다. WhatsApp이 100퍼센트 완료를 알리고 몇 초 동안 이벤트가 없으면 세션이 끝난다.
  • 메시지 가져오기(pull)는 저장된 인증 정보로 다시 연결한다. WhatsApp은 연결이 끊긴 동안 대기시킨 내용을 보낸 뒤 receivedPendingNotifications 이벤트를 보낸다. 몇 초 동안 이벤트가 없으면 세션이 끝난다.

호출자는 하나의 세션을 공유한다. 세션 하나가 실행되는 동안 두 번째 pull()이 호출되면 같은 Promise를 돌려준다.

연결 세션은 이를 시작한 HTTP 요청보다 오래 실행될 수 있다. startPairing은 Durable Object 알람을 설정하고, alarm()은 세션 Promise가 끝날 때까지 기다린다. 실행 중인 알람 처리기가 객체를 계속 유지한다. 연결 페이지는 상태만 확인하므로 페이지를 닫아도 가져오기는 중단되지 않는다.

유휴 상태를 판단할 때는 데이터 이벤트(messaging-history.set, messages.upsert)만 센다. 연결 상태 메시지 같은 이벤트는 세지 않는다. 모든 이벤트를 세면 계속 발생하는 접속 상태와 연락처 업데이트 때문에 세션이 끝나지 않을 수 있다.

데이터 모델

모든 데이터는 Durable Object의 SQLite에 저장되며, 스키마는 store.ts의 initSchema에 정의돼 있다.

  • messages(chat, id, ts, from_me, sender, push_name, type, body)는 (chat, id)를 기본 키로 쓴다. 미디어는 body에 표시 문자열로 저장한다. 수정된 메시지는 body를 갱신하고 삭제된 메시지는 [deleted]로 표시한다.
  • messages_fts는 body를 대상으로 하는 FTS5 전문 검색 인덱스이며 트리거가 동기화한다.
  • chats(jid, name, is_group)와 contacts(jid, name, notify)는 채팅과 연락처 정보를 저장한다. 표시 이름은 채팅 이름, 연락처 이름, 푸시 이름, 전화번호 순으로 결정한다.
  • alias(lid, pn)은 WhatsApp이 익명 @lid 식별자를 도입하는 데 대응한다. 이를 전화번호 기반 JID에 연결하며, addAlias가 기존 행을 전화번호 형식으로 합쳐 한 사람의 채팅이 하나로 보이게 한다.
  • auth(k, v)는 Baileys 인증 정보와 Signal 키를 저장한다. Baileys의 BufferJSON으로 버퍼를 인코딩한 JSON이다.
  • meta(k, v)에는 현재 last_pull만 저장한다.

데이터 수집은 멱등적이다. 기록을 다시 가져올 때 기존 행을 갱신하되 빠진 sender나 push_name을 채우고 중복을 만들지 않는다. 따라서 재연결해도 안전하다.

코드를 바꾸기 전에 알아둘 점

아래 내용은 실제로 원인을 찾는 데 시간이 들었던 부분이다. Baileys 관련 코드를 수정하기 전에 확인해야 한다.

  • Baileys가 workerd에서 작동하려면 세 가지 보완이 필요하다. ws 패키지는 Node 소켓을 요구한다. ws-shim.ts는 Workers WebSocket을 감싼다. WhatsApp이 확인하는 Origin 헤더를 설정할 수 있는 유일한 방법이므로 fetch()와 Upgrade 헤더를 사용해 연결한다. whatsapp-rust-bridge는 wasm을 base64로 포함하고 실행 중 컴파일하지만 workerd는 이를 허용하지 않는다. vendor-bridge.mjs가 wasm을 파일로 저장하고 로더를 수정해 CompiledWasm 모듈로 가져온다. vendor/ 폴더는 생성물이며 저장소에 커밋하지 않는다. Node 타이머에는 .unref()가 있지만 workerd 타이머에는 없다. polyfills.ts가 이를 추가하며 Worker와 Durable Object 모듈 모두에서 가장 먼저 가져와야 한다.
  • lru-cache의 자동 정리 기능은 첫 기록 가져오기를 망가뜨린다. Baileys가 ttlAutopurge: true인 캐시를 만들면 항목마다 setTimeout이 시작된다. workerd는 객체 하나당 대기 중인 타이머를 10,000개까지 허용하며, 첫 기록 동기화 때 이를 넘어 QuotaExceededError가 발생한다. lru-shim.ts는 자동 정리를 끈다. 만료된 항목은 접근할 때 지연 정리한다.
  • QR 연결 과정에서는 creds.registered가 설정되지 않는다. 이 플래그는 휴대전화 등록 절차에 해당한다. 연결 여부는 creds.me?.id로 확인한다.
  • 기록은 링크 하나당 한 번만 전달된다. 첫 가져오기가 중간에 실패하면 재연결할 때까지 해당 기록을 다시 받을 수 없다. 수집 코드를 배포하기 전 실제 첫 연결로 시험해야 한다.
  • 그룹 메시지의 발신자 정보는 두 위치에서 온다. 실시간 메시지는 key.participant에 있고, 기록 메시지는 msg.participant에 있다. 두 위치를 모두 읽어야 한다.
  • Baileys 버전을 정확히 고정해야 한다. 프리릴리스이며 릴리스 후보 버전마다 API가 바뀐다.
  • 배포해도 사용 중인 Durable Object의 실행이 중단되지 않는다. 활성 세션이 있는 객체는 세션이 끝날 때까지 이전 코드로 계속 실행된다. 마이그레이션에서 클래스를 이름 변경해도 이전 실행을 중단하지 못했다. 수정 사항을 배포했는데 객체가 여전히 잘못 작동하면 세션 제한 시간인 10분이 지나거나 객체가 유휴 상태가 될 때까지 기다린다.
  • 로그아웃은 두 단계로 이뤄진다. logout()은 먼저 연결해 Baileys의 logout()을 호출해 휴대전화의 기기 목록에서 기기를 제거한 뒤 로컬 인증 정보를 지운다. 첫 단계가 실패하면 디버그 이벤트에 오류가 표시되며, 사용자가 직접 기기를 제거해야 한다.
  • markOnlineOnConnect를 호출하지 않는다. 의도적으로 false로 설정돼 있다. 연동 기기를 온라인으로 표시하면 휴대전화에서 알림이 표시되지 않게 된다.

설정 항목

이름종류목적
OWNER_PASSWORD비밀 값기본 비밀번호 로그인 방식에 쓴다. 12자 미만이면 로그인을 거부한다.
LOGIN_ORIGIN변수로그인 서비스 방식으로 전환할 때 설정한다.
OWNER_EMAIL변수로그인 서비스 방식에서 허용할 단 하나의 이메일 주소다.
ALLOWED_ORIGINS변수로그인 서비스 방식에서 토큰을 허용할 출처를 쉼표로 구분해 지정한다. 운영 환경에는 운영 출처만 지정해야 한다.
PIN선택적 비밀 값32자 이상의 무작위 문자열을 설정하면 x-pin 헤더로 /debug 경로를 사용할 수 있다. 설정하지 않으면 해당 경로를 사용할 수 없다.
OAUTH_KV바인딩OAuth 제공자 토큰과 로그인 대기 정보를 저장한다.
WA바인딩Durable Object다.

배포별 도메인, KV ID, 이메일 등의 값은 Git에서 제외되는 wrangler.local.jsonc에 둔다. npm run deploy:local로 배포한다. wrangler.jsonc는 다른 사용자가 시작할 때 쓰는 템플릿이다.

프로젝트를 수정하는 방법

  • 도구를 추가하려면 mcp.ts에 등록하고 SQL은 store.ts에 작성한다. 읽기 도구는 먼저 fresh()를 호출해야 한다. 다른 의도가 없다면 도구는 읽기 전용으로 유지한다.
  • 로그인 사용자를 바꾸려면 auth.ts 아래쪽의 tokenFromPassword, tokenFromLoginCode, isOwnerToken 내보내기를 바꾼다. 나머지 코드는 불투명한 전달자 토큰만 본다.
  • 다중 사용자를 지원하려면 현재 index.ts의 wa()와 mcp.ts의 handleMcp가 고정 이름 owner로 Durable Object를 지정하고, OAuth 속성의 userId도 상수 'owner'로 설정한다. 로그인한 사용자 신원에서 두 값을 도출하고 그 신원에 따라 객체를 지정해야 한다. 다른 사람의 메시지 기록을 저장해도 되는지 먼저 검토해야 한다.
  • 빠진 연락처 이름을 채우는 방법으로는 Google Contacts CSV를 가져와 전화번호를 기준으로 contacts 테이블과 대조하는 방안이 있다. store.ts의 upsertContacts는 이미 { id, name }을 받는다. Google Contacts를 실시간 동기화하려면 연락처 범위를 허용하는 별도의 OAuth 승인이 필요하다. 앱이 “Testing” 상태에 있으면 Google은 7일 뒤 새로 고침 토큰을 만료시킨다.
  • 메시지 전송은 구현돼 있지 않으며 의도적인 선택이다. 추가하기 전에 신중히 검토해야 한다.

디버깅과 확인

PIN을 설정하면 아래 경로는 JSON을 반환하며 x-pin: <PIN> 헤더가 필요하다.

  • /debug: 세션 단계, 연결 여부, 개수, 세션의 최근 이벤트(연결 변경, 기록 청크 크기, 오류)를 보여준다.
  • /debug/pull: 메시지를 즉시 가져온다.
  • /debug/chats?q=, /debug/messages?chat=, /debug/search?q=, /debug/types: MCP를 거치지 않고 저장소를 읽는다.
  • /debug/logout: relink와 같은 기능을 한다.

로컬 개발에서는 12자 이상인 OWNER_PASSWORD=...를 담은 .dev.vars 파일을 만들고 npm run dev를 실행한다. 로컬 Durable Object는 비어 있는 별도 저장소를 사용하므로 로그인 절차를 확인하고 실제 QR 코드를 볼 수 있다. 다만 로컬 개발 환경에서 휴대전화를 연결하면 두 번째 연동 기기가 생긴다.

휴대전화 없이 연결 화면의 상태를 시험하려면 /pair/status를 모의 응답하도록 페이지를 제공하면 된다. 페이지에 필요한 JSON 구조는 paired, running, progress, qrSvg, messages, chats뿐이다.

변경 사항은 다음 순서로 확인한다.

text
1. npm run typecheck
2. 배포한 뒤 PIN을 사용해 GET /debug를 호출한다. 결과는 phase: "idle"이고 paired: true여야 한다.
3. GET /debug/pull을 호출한다. 메시지가 없는 계정이라면 약 10초 뒤 reason: "idle"을 반환해야 한다.
4. list_chats를 호출한 뒤 히브리어나 이모지가 많은 채팅에서 get_messages를 실행하고, 이어 search_messages를 호출한다.
5. 수집 코드를 수정했다면 다시 연결해 저장된 메시지 수가 늘지 않는지(재가져오기는 멱등적이다), 그룹 메시지에 실제 발신자 이름이 표시되는지 확인한다.

알려진 미비점은 다음과 같다.

  • 연락처 이름이 없는 채팅은 전화번호로 표시된다. 자세한 내용은 “빠진 연락처 이름 채우기”를 참고한다.
  • 하루 한 번의 가져오기에서 세션 로그아웃이 발견돼도 사용자에게 알리지 않는다. 에이전트가 다음에 요청할 때 link_status에서 확인할 수 있다.
  • 편집과 삭제는 프로토콜 메시지가 도착하면 반영된다. 이 경로는 실제 메시지로 시험하지 않았다.
  • 실제 계정 하나에서만 사용해 봤다.

라이선스는 MIT다.