Show HN:开源 WhatsApp MCP 服务器可在 Cloudflare 上自行部署
Show HN: WhatsApp MCP running free on Cloudflare
作者在 GitHub 开源 WhatsApp MCP 服务器,让 AI 智能体(Claude Code、Claude Desktop 等任何 MCP 客户端)读取用户自己的 WhatsApp 聊天记录。
原文给出在 Cloudflare 上自行部署 WhatsApp MCP 服务器的完整步骤与架构说明,读者可据此为自己的 Agent 搭建只读的 WhatsApp 消息入口。
让你的 AI 代理读取你的 WhatsApp 消息。
这是一个运行在你自己的 Cloudflare 账户中的小型服务器。你只需像关联 WhatsApp Web 那样把你的手机与它关联一次,此后任何 MCP 客户端(Claude Code、Claude Desktop,以及任何支持 MCP 的工具)都能搜索并读取你的聊天记录。它可以读取和搜索你的消息,并记住你为手机号设置的备注名称。它不能发送任何内容。
你可以这样向你的代理提问:
- "Dana 怎么说的航班的事?"
- "在我所有的聊天中,找出所有有人提到过租约的消息。"
- "帮我总结一下本周家庭群里我漏看的内容。"
工作原理
你的手机仍然是真实数据的来源。服务器是一个已链接设备,由你的代理与它通信。
它不会一直保持与 WhatsApp 的连接。当你的代理请求某些内容时,以及每天一次,它会进行连接,收取 WhatsApp 自上次以来为其排队的所有内容,将其写入自己的数据库,然后挂断。你的手机保持正常工作,并继续接收通知。
你的消息保存在你 Cloudflare 账户中 Durable Object 内的 SQLite 数据库里。除此以外不会发送到任何其他地方。
部署步骤
你需要:一个 Cloudflare 账户(需使用 Workers 付费套餐,见下方备注)、Node 20 或更高版本,以及你的手机。
git clone https://github.com/finereli/whatsapp-mcp cd whatsapp-mcp npm install # 1. A place for the sign-in tokens. Paste the id it prints into wrangler.jsonc. npx wrangler kv namespace create OAUTH_KV # 2. A password for the page that links your phone. 12+ characters. npx wrangler secret put OWNER_PASSWORD # 3. Deploy. npm run deploy
Wrangler 会打印出你的 URL,形如 https://whatsapp-mcp.your-name.workers.dev。打开它。首页包含剩下的两个步骤:
-
关联 WhatsApp。使用 WhatsApp 扫描二维码(设置 → 已链接设备 → 链接设备)。如果你是在手机上且无法扫描屏幕,可改用手机号选项。首次关联会导入你的历史消息。页面会显示进度,你可以将其关闭。
-
连接你的代理。在 Claude Code 中:
claude mcp add --transport http whatsapp https://your-url/mcp
然后输入
/mcp并使用密码登录。其他客户端:将它们指向相同的/mcpURL 即可。
如果你想使用自己的域名,请参见 wrangler.jsonc 底部的注释。
关于 Workers 付费套餐的说明
导入大量历史记录会一次性解码大量数据,而 Workers 的默认 CPU 限制对此而言太小。wrangler.jsonc 会调高该限制,而调高限制需要付费套餐,每月仅需几美元。我没有试用过免费套餐。如果你使用的是免费套餐,请移除 limits 代码块,并对首次导入可能不稳定有所预期。
使用须知
这不是官方产品。WhatsApp 并没有提供用于读取个人消息的 API。本项目使用的是 WhatsApp Web 所采用的相同协议,通过名为 Baileys 的开源库实现。这违反了 WhatsApp 的服务条款。对于只读取消息的已链接设备,实际风险较低,但并非为零。请仅在你自己的账户上使用,并且不要使用它来发送消息。
你能获取的只是 WhatsApp 发送给你的内容。首次关联会导入 WhatsApp 决定交给新设备的历史消息。它可能并非全部内容。每次关联只会执行一次导入。若要再次导入,请重新关联(你已存储的消息会被保留,重新导入会补全缺失的部分)。
部分聊天会显示为手机号。WhatsApp 并不会将所有联系人姓名都提供给已链接设备,其中大多数是你从未保存过联系人的人发来的一次性消息。对于那些重要的聊天,你的代理可以帮你修复:它会列出未命名的聊天,使用它已有的联系人工具(例如 Google Contacts 或 Gmail 连接器)去你的通讯录中查找这些号码,并保存姓名。服务器本身不会连接任何联系人服务。详见下方的"为聊天命名"。
不会下载媒体文件。照片、语音消息和文档会显示为类似 [image] 或 [voice note 12s] 这样的标签,并附上说明文字(如果有的话)。不会进行任何转录。
这是为单人设计的。一个部署对应一个 WhatsApp 账户和一个所有者。这是刻意为之的:每个部署都保存着完整的聊天记录并保持对账户的实时登录,因此每个人都应当运行自己的部署。
重新关联。如果你的手机显示该设备已被移除,或者你想重新开始,请让你的代理运行 relink,或者打开 /setup 并选择 "取消关联并重新扫描"。扫描一个新的二维码即可。已保存的消息会保留。
你的代理可以做什么
| 工具 | 功能 |
|---|---|
list_chats |
聊天列表,按最近消息排序。可按姓名或号码筛选。 |
get_messages |
读取单个聊天。可按日期前后翻页。 |
search_messages |
在全部内容中或单个聊天内进行全文搜索。 |
unnamed_chats |
仅以电话号码标识的聊天,按活跃度排序,并显示对方最后一条消息。 |
set_contact_names |
为电话号码保存备注名,格式不限。备注与号码绑定,因此也会显示在群聊中。 |
sync_now |
立即拉取新消息。 |
link_status |
查看是否已关联、上次拉取时间以及已存储的消息量。 |
relink |
取消关联并重新开始。需要人工扫描新的二维码。 |
任何读取工具在调用时,如果本地缓存已超过十分钟,会先从 WhatsApp 同步最新数据。
为聊天命名
WhatsApp 只会把服务器已知联系人姓名提供过来,其余都以电话号码形式显示。与其让服务器登录通讯录服务(这意味着要搭建云项目、配置授权页,对 Google 来说还要过应用审核),不如把这件事交给你的代理——它通常已经具备访问你通讯录的能力。
对代理说 "为未命名的聊天查找名字"。代理会调用 unnamed_chats,将号码与你的通讯录匹配,并通过 set_contact_names 保存找到的结果。它无法覆盖所有情况:大多数未命名的聊天只是陌生人发来的一条消息。
代理会自动被引导去做这件事。服务器的说明中提到了这一点,"未找到聊天"和"无匹配结果"的提示中也会再次强调——因为这正是代理在名字问题上卡壳的时刻。link_status 还会显示有多少聊天尚未命名。
面向代理,以及修改本项目的人
以下内容面向所有修改代码的人,包括在 fork 上工作的 AI 代理。本文件开头的部分是面向部署所有者的。
整体结构
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 命名空间。
文件
| 文件 | 职责 |
|---|---|
src/index.ts |
路由。OAuth 提供方配置、登录、配对端点、/debug、定时任务。 |
src/auth.ts |
谁是所有者。两种模式:密码登录,或第三方登录服务。同时负责所有者的浏览器会话。 |
src/phone.ts |
将任意格式的电话号码转换为 WhatsApp jid。 |
src/whatsapp-do.ts |
Durable Object。运行 Baileys 会话,向 Worker 提供 RPC 方法。 |
src/store.ts |
数据库结构、将 Baileys 事件写入 SQLite,以及所有查询。 |
src/auth-state.ts |
由 Durable Object 的 SQLite 支撑的 Baileys 认证状态(替代 useMultiFileAuthState)。 |
src/mcp.ts |
MCP 工具,以及引导代理使用命名工具的提示信息。 |
src/pages.ts, src/landing.ts, src/privacy.ts, src/tos.ts, src/ui.ts |
纯 HTML。ui.ts 中定义共享的设计 token。 |
src/ws-shim.ts, src/lru-shim.ts, src/polyfills.ts |
使 Baileys 能在 workerd 上运行。详见下文。 |
scripts/vendor-bridge.mjs |
从 Baileys 的 Rust 桥接层提取 wasm。运行于 npm install。 |
local-export/ |
一个独立的一次性脚本,可将聊天记录导出为 JSON 文件,无需服务器。不属于部署的一部分。 |
会话的工作原理
所有 WhatsApp 流量都在 whatsapp-do.ts 中的 runSession() 内完成。会话分为两种:
- 配对(
startPairing)。此时还没有凭据。Baileys 输出 QR 字符串,保存在内存中,由pairStatus()渲染为 SVG。扫描后,WhatsApp 以代码 515 关闭套接字(需要重启),会话使用新凭据重新连接。历史记录以messaging-history.set分块的形式到达,并带有progress进度百分比。当 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)和custom_names(jid, name)。显示名称按以下顺序解析:WhatsApp 的聊天名、WhatsApp 的联系人名、已保存的名字、本人的 push name,最后才是手机号。custom_names是set_contact_names写入的内容。alias(lid, pn)。WhatsApp 正在转向匿名的@lid标识符。本表将它们映射到手机号形式的 JID,addAlias把已有行折叠为手机号形式,从而保证一个人对应一个聊天。auth(k, v),Baileys 凭据与 signal 密钥,JSON 中的 Buffer 由 Baileys 的BufferJSON编码。meta(k, v),目前仅有last_pull。
导入是幂等的。重新导入历史记录会执行 upsert:补齐缺失的 sender 或 push_name,且不会产生重复。这正是重新关联能够安全进行的原因。
改动之前需要了解的事
这些经验都是耗时摸索出来的。在改动 Baileys 相关部分之前,请先阅读。
- Baileys needs three shims to run on workerd.
ws:该包需要 Node 的 socket。ws-shim.ts对 Workers 的 WebSocket 进行了封装,通过fetch()打开,并附带Upgrade请求头,因为这是设置 WhatsApp 所校验的Origin请求头的唯一方法。whatsapp-rust-bridge:它以 base64 形式分发其 wasm,并在运行时进行编译,而 workerd 不允许这样做。vendor-bridge.mjs将 wasm 写入文件,并修改 loader,使其作为CompiledWasm模块导入。vendor/目录是自动生成的,不纳入版本管理。- 定时器:Node 的定时器带有
.unref(),而 workerd 的没有。polyfills.ts补上了该能力。它必须是 Worker 模块和 Durable Object 模块中的第一个 import。
lru-cache的 autopurge 会破坏首次导入。Baileys 使用ttlAutopurge: true创建缓存,后者会为每条记录启动一个setTimeout。workerd 允许每个对象最多 10,000 个待处理定时器,而首次历史同步就会以QuotaExceededError突破该上限。lru-shim.ts用于关闭 autopurge。过期条目仍会在访问时以懒清理方式被删除。- QR 配对永远不会设置
creds.registered。该标志属于移动端注册流程。判断"是否已关联"应使用creds.me?.id。 - 每次关联只能拉取一次历史记录。若首次导入中途失败,在重新关联之前该段历史将无法再次获取。在未对真实首次关联进行测试之前,请勿发布对导入代码的改动。
- 群组发送者会出现在两个地方。实时消息中位于
key.participant,历史消息中则放在msg.participant。两处都要读取。 - 请精确锁定 Baileys 版本。它仍是预发布版本,其 API 在不同 RC 之间会发生变化。
- 部署不会驱逐仍在使用的 Durable Object。拥有活跃会话的对象会持续运行旧代码,直到会话结束才停止。对类进行重命名并配合迁移也无法将其驱逐。如果你已经发布了修复,而对象仍然行为异常,请停止向它发送请求并等待三到五分钟。每次探测都会重置其空闲计时器,包括
/debug,所以对一个陈旧对象进行轮询会让它一直保持陈旧状态。Baileys 还在套接字关闭时泄漏 interval 定时器;在whatsapp-do.ts中加入finish()可以清除它们,使对象最终能够进入空闲。 - 登出需要两步。
logout()首先连接并调用 Baileys 的logout(),使设备从手机列表中消失,然后清除本地凭据。如果第一步失败,调试事件中会显示相应信息,用户需要手动移除该设备。 - WhatsApp 在应当是名字的位置存的是一个占位符。没有对应联系人的聊天会以号码命名,在聊天名称和联系人名称中,有时还会被掩码(如
+972∙∙∙∙∙∙∙20)。store.ts中的numberLike将这些视为无名称,而 ingest 会拒绝存储它们。在该修复之前,占位符会优先于真实保存的名称。 - 不要调用
markOnlineOnConnect。它是故意设置为 false 的。将已关联设备标记为在线会让手机停止显示通知。
配置
| 名称 | 类型 | 用途 |
|---|---|---|
OWNER_PASSWORD |
secret | 密码模式(默认)。至少 12 个字符,否则登录会被拒绝。 |
OWNER_EMAIL |
var | 登录服务模式:仅放行指定的一个邮箱。 |
OPERATOR_NAME |
var,可选 | 在隐私页面上显示,作为本服务器的运营者。 |
DEFAULT_COUNTRY_CODE |
var,可选 | 国家代码(数字,不含加号),当你的代理保存的号码未带国家代码时使用,例如972。 |
LOGIN_ORIGIN |
var | 将此变量设置后即可切换到登录服务模式。 |
ALLOWED_ORIGINS |
var | 登录服务模式:以逗号分隔的来源列表,只有这些来源的令牌会被接受。生产环境应仅列出生产域名。 |
PIN |
secret,可选 | 32 位以上随机字符。通过x-pin请求头启用/debug相关路由。不设置则保持关闭。 |
OAUTH_KV |
binding | OAuth 提供方与待处理登录的令牌存储。 |
WA |
binding | Durable Object 本身。 |
将某个部署自己的配置(域名、KV id、邮箱)放在wrangler.local.jsonc中(该文件已被 .gitignore 忽略),并通过npm run deploy:local进行部署。wrangler.jsonc是供他人使用的模板。
改造它
- 添加工具。在
mcp.ts中注册它,并把 SQL 放在store.ts中。读取类工具应先调用fresh()。除非你确有把握,否则让工具保持只读。如果某个失败会让代理陷入停滞,请在工具结果中告知下一步该怎么做,正如NAME_HINT在名字缺失时所做的那样。 - 更改允许登录的对象。
auth.ts选择一个模式(authMode),index.ts中的路由随之确定。如需增加身份提供方,请在那里添加一个模式以及一个回调路由。其余代码只看到一个不透明的 bearer token。 - 使其支持多用户。目前 Durable Object 使用固定名称
owner进行寻址(index.ts中的wa(),mcp.ts中的handleMcp),且 OAuth props 中的常量'owner'就是userId。若要支持多用户,请让两者均从已登录的身份派生,并以该身份作为对象的键。请先想清楚你是否愿意持有他人的消息历史。 - 补充名字的其他方式。任何能产出
{ jid, name }行的内容都可以调用store.setCustomNames。CSV 上传、CardDAV 拉取或定时任务都可以。之所以提供代理路由,是为了使服务器在执行这些操作时无需任何凭据。 - 发送消息。并未实现,是有意的决定。请在添加之前慎重考虑。
调试
设置PIN后,以下接口返回 JSON 并需要请求头x-pin: <PIN>:
/debug:会话阶段、是否已关联、各项计数以及来自会话的最近事件(连接变化、历史块大小、错误)。/debug/pull:立即执行一次拉取。/debug/chats?q=、/debug/messages?chat=、/debug/search?q=、/debug/types:无需通过 MCP 即可读取存储。/debug/unnamed:聊天记录unnamed_chats将会返回。/debug/sql?q=:对存储执行一次只读SELECT,用于排查名称或计数为何看起来异常。/debug/logout:与relink相同。
本地开发:使用 npm run dev 配合一个包含 OWNER_PASSWORD=...(12 个以上字符)的 .dev.vars。它会运行一个本地 Durable Object,使用其自带的空存储,因此你可以演练登录流程并查看真实的二维码,但从本地开发关联手机会创建第二个已关联设备。
要在没有手机的情况下测试配对 UI 状态,请用一个模拟的 /pair/status 提供页面。页面所需的全部内容就是这一种 JSON 结构:paired、running、progress、qrSvg、messages、chats。
检查一次更改
npm run typecheck,然后npm test。该测试使用 Node 内置的 SQLite(Node 22.13 或更高版本)运行真实的存储代码,包括从旧版本的迁移。该 SQLite 不支持 FTS5,因此搜索功能本身不在覆盖范围内。- 部署,然后用 PIN
GET /debug。它应当返回phase: "idle"和paired: true。 GET /debug/pull。一个无活动的账号应在大约十秒内返回reason: "idle"。- 调用
list_chats,然后在希伯来语或含大量 emoji 的聊天上get_messages,再search_messages。然后unnamed_chats,对一个号码set_contact_names,并确认聊天中显示该名称。 - 如果你修改了摄取(ingest)相关代码,请重新关联并确认存储的消息数量没有增长(重新导入是幂等的),以及群聊消息显示真实的发送者名称。
已知不足
- WhatsApp 中没有名称且也未保存的聊天会显示为电话号码。名称不会自动刷新:只有当代理(或你)执行操作时才会被保存。
- 如果每日拉取发现会话已登出,不会通知你。
link_status会在你的代理下次询问时显示这一点。 - 编辑和删除会在作为协议消息到达时被应用。该路径尚未针对真实的编辑和删除进行测试。
- 它仅针对一个真实账号进行过演练。
许可证
MIT
来源:Hacker News · github.com