适用场景
你希望自己的智能体(Claude Desktop、Cursor、自建 Agent)能直接收发 WhatsApp 消息,但不想为一台 24 小时在线的服务器付费、也不想管证书和进程守护。这套方案把「WhatsApp 回调入口」和「MCP 工具端点」都放在同一个 Cloudflare Worker 里:Meta 把消息推给 Worker,Worker 用 Durable Object 记住会话,智能体再通过 AI 词典:MCP 协议">MCP 协议读取和发送消息。适合个人开发者、小团队做客服助手、通知机器人或消息类工具的原型验证。
环境与前置条件
账号
- Cloudflare 账号(Workers 免费计划即可,Durable Objects 免费额度与可用范围以 Cloudflare 官方定价页当前说明为准)
- Meta 开发者账号,能创建一个 Business 类型的应用并添加 WhatsApp 产品
- 一个能接收短信/电话的手机号,用于注册 WhatsApp 测试号码
本地环境
- Node.js LTS 版本(以 Node 官方文档当前 LTS 为准),自带 npm
- 命令行工具:macOS/Linux 用终端,Windows 用 PowerShell 或 WSL
- 不需要 Docker,不需要 GPU;本项目本地内存占用很小,普通办公本即可,磁盘留出几百 MB 给
node_modules
你需要提前拿到的四个值
| 名称 | 从哪里来 | 用途 |
|---|---|---|
PHONE_NUMBER_ID | Meta 应用 → WhatsApp → API Setup | 发送消息时拼进 Graph API 地址 |
WHATSAPP_TOKEN | 同上,或 System User 生成的长期令牌 | 调用发送接口的凭证 |
APP_SECRET | Meta 应用 → 设置 → 基本 | 校验回调签名,防止伪造请求 |
VERIFY_TOKEN | 自己随便生成一串随机字符 | Meta 校验回调地址时比对 |
另外还需要一个 MCP_TOKEN,同样自己生成,用来保护你的 MCP 端点不被别人调用。
两个必须知道的 WhatsApp 规则
1. 测试号码只能给「已加入收件人列表」的号码发消息,列表数量有上限,具体以官方文档为准。
2. 用户最后一次给你发消息后的 24 小时内,你可以自由回复文本;超过这个窗口只能用预先审核通过的模板消息。
分步骤部署
第 1 步:在 Meta 侧准备好应用和测试号码
1. 打开 Meta for Developers,创建应用,类型选 Business。
2. 在应用面板里添加「WhatsApp」产品,系统会自动分配一个测试号码。
3. 进入 WhatsApp → API Setup,记下 Phone number ID,并把你要用来测试的手机号加到「To」列表里,点发送验证码完成验证。
4. 在「设置 → 基本」里找到 App Secret,点显示并复制。
5. 生成访问令牌:页面上的临时令牌有效期很短(以官方文档为准),建议按官方文档创建一个 System User 并生成长期令牌,避免天天换。
这一步成功的标志:API Setup 页面能直接给你自己的手机发一条测试消息,手机上收到了。
第 2 步:初始化 Worker 项目
```bash
npm create cloudflare@latest -- wa-mcp
cd wa-mcp
```
交互选项里选:模板选 Hello World / Worker + TypeScript,部署方式选稍后手动部署。然后安装依赖:
```bash
npm i agents @modelcontextprotocol/sdk zod
```
agents 是 Cloudflare 的智能体 SDK,里面提供了 McpAgent 基类,帮我们处理 MCP 的握手、会话和 SSE 流;@modelcontextprotocol/sdk 提供 McpServer;zod 用来声明工具参数。
成功的标志:目录里出现 src/index.ts、wrangler.jsonc,npm ls agents 能列出已安装的包。
第 3 步:改 wrangler.jsonc,绑定 Durable Object
打开 wrangler.jsonc,改成下面这样(保留模板里原有的 name 和 main):
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wa-mcp",
"main": "src/index.ts",
// 用模板生成的日期即可,或换成你今天创建项目的日期
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"vars": {
"GRAPH_VERSION": "vXX.X",
"PHONE_NUMBER_ID": "你的 Phone number ID"
},
"durable_objects": {
"bindings": [
{ "name": "MCP_OBJECT", "class_name": "WhatsAppMCP" },
{ "name": "CHAT_STATE", "class_name": "ChatState" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["WhatsAppMCP", "ChatState"] }
]
}
```
要点说明:
GRAPH_VERSION填 Meta Graph API 的版本号,写v加数字,具体版本以 Meta 官方文档当前版本为准,不要照抄示例。- 迁移里必须用
new_sqlite_classes而不是new_classes。SQLite 存储后端是免费计划可用的那一类,用错了会部署失败。 - 迁移
tag一旦部署过就不能改内容,后续要改结构只能新增v2。 - 如果你更习惯 TOML,
wrangler.toml写法等价,二选一。
第 4 步:写会话状态 Durable Object
新建 src/index.ts,先写状态层。它做两件事:按消息 ID 去重(Meta 在超时重试时会重复推送同一条消息),以及保存最近 100 条入站消息供智能体读取。
```ts
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { DurableObject } from "cloudflare:workers";
import { z } from "zod";
export interface Env {
WHATSAPP_TOKEN: string;
PHONE_NUMBER_ID: string;
GRAPH_VERSION: string;
VERIFY_TOKEN: string;
APP_SECRET: string;
MCP_TOKEN: string;
MCP_OBJECT: DurableObjectNamespace<WhatsAppMCP>;
CHAT_STATE: DurableObjectNamespace<ChatState>;
}
type InboundMessage = {
id: string;
from: string;
name?: string;
text: string;
ts: number;
};
export class ChatState extends DurableObject<Env> {
async record(m: InboundMessage): Promise<"stored" | "duplicate"> {
const seen = (await this.ctx.storage.get<string[]>("seen")) ?? [];
if (seen.includes(m.id)) return "duplicate";
seen.push(m.id);
await this.ctx.storage.put("seen", seen.slice(-300));
const log = (await this.ctx.storage.get<InboundMessage[]>("log")) ?? [];
log.push(m);
await this.ctx.storage.put("log", log.slice(-100));
return "stored";
}
async recent(limit = 10): Promise<InboundMessage[]> {
const log = (await this.ctx.storage.get<InboundMessage[]>("log")) ?? [];
return log.slice(-limit);
}
}
function state(env: Env) {
return env.CHAT_STATE.get(env.CHAT_STATE.idFromName("inbox"));
}
```
idFromName("inbox") 表示所有消息都进同一个实例,这样读写的是同一份会话记录。
第 5 步:写 MCP 工具(发消息 + 读消息)
继续在 src/index.ts 里追加。先写一个调用 Graph API 的发送函数:
```ts
async function sendText(env: Env, to: string, text: string): Promise<string> {
const url = https://graph.facebook.com/${env.GRAPH_VERSION}/${env.PHONE_NUMBER_ID}/messages;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: Bearer ${env.WHATSAPP_TOKEN},
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to,
type: "text",
text: { body: text },
}),
});
const body = await res.text();
if (!res.ok) throw new Error(发送失败 ${res.status}: ${body});
return body;
}
```
再写 MCP 主体。init() 里注册的工具,就是智能体能看到的「函数」:
```ts
export class WhatsAppMCP extends McpAgent<Env> {
server = new McpServer({ name: "whatsapp-mcp", version: "1.0.0" });
async init() {
this.server.tool(
"send_whatsapp_message",
"给指定 WhatsApp 号码发送一条文本消息",
{
to: z.string().describe("国际格式号码,只含数字,例如 8613800000000"),
text: z.string().describe("消息正文"),
},
async ({ to, text }) => {
await sendText(this.env, to, text);
return { content: [{ type: "text", text: 已发送给 ${to} }] };
}
);
this.server.tool(
"list_recent_messages",
"读取最近收到的 WhatsApp 消息",
{ limit: z.number().int().min(1).max(50).default(10) },
async ({ limit }) => {
const rows = await state(this.env).recent(limit);
const text = rows.length
? rows
.map((r) => [${new Date(r.ts * 1000).toISOString()}] ${r.name ?? r.from}: ${r.text})
.join("\n")
: "暂无消息";
return { content: [{ type: "text", text }] };
}
);
}
}
```
说明:新版 MCP SDK 也提供 registerTool 写法,参数结构略有差别,以官方文档当前版本为准。this.env 就是 Worker 的绑定环境,密钥直接从这里取。
第 6 步:写 webhook 入口(GET 验证 + POST 收消息)
```ts
function extractMessages(payload: any): InboundMessage[] {
const out: InboundMessage[] = [];
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value ?? {};
const contacts = value.contacts ?? [];
for (const msg of value.messages ?? []) {
if (msg.type !== "text") continue;
const contact = contacts.find((c: any) => c.wa_id === msg.from);
out.push({
id: msg.id,
from: msg.from,
name: contact?.profile?.name,
text: msg.text?.body ?? "",
ts: Number(msg.timestamp ?? Math.floor(Date.now() / 1000)),
});
}
}
}
return out;
}
async function verifySignature(secret: string, raw: string, header: string | null) {
if (!header) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(raw));
const expected =
"sha256=" +
[...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
if (expected.length !== header.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ header.charCodeAt(i);
}
return diff === 0;
}
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
// Meta 校验回调地址时会发 GET
if (request.method === "GET") {
const mode = url.searchParams.get("hub.mode");
const token = url.searchParams.get("hub.verify_token");
const challenge = url.searchParams.get("hub.challenge") ?? "";
if (mode === "subscribe" && token === env.VERIFY_TOKEN) {
return new Response(challenge, {
status: 200,
headers: { "content-type": "text/plain" },
});
}
return new Response("forbidden", { status: 403 });
}
// 必须拿原始文本算签名,不能先 JSON.parse
const raw = await request.text();
const ok = await verifySignature(env.APP_SECRET, raw, request.headers.get("x-hub-signature-256"));
if (!ok) return new Response("invalid signature", { status: 401 });
let payload: any;
try {
payload = JSON.parse(raw);
} catch {
return new Response("bad json", { status: 400 });
}
const messages = extractMessages(payload);
if (messages.length) {
// 先返回 200,写库放到后台,避免 Meta 因超时重推
ctx.waitUntil(
Promise.all(messages.map((m) => state(env).record(m))).then(() => undefined)
);
}
return new Response("ok", { status: 200 });
}
```
最后是路由。/mcp 用 Bearer Token 挡住,/webhook 交给上面的函数:
```ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/mcp") {
if (request.headers.get("authorization") !== Bearer ${env.MCP_TOKEN}) {
return new Response("unauthorized", { status: 401 });
}
return WhatsAppMCP.serve("/mcp").fetch(request, env, ctx);
}
if (url.pathname === "/webhook") {
return handleWebhook(request, env, ctx);
}
return new Response("ok", { status: 200 });
},
};
```
第 7 步:写入密钥
密钥不要写进 wrangler.jsonc,用 secret 存:
```bash
npx wrangler secret put WHATSAPP_TOKEN
npx wrangler secret put APP_SECRET
npx wrangler secret put VERIFY_TOKEN
npx wrangler secret put MCP_TOKEN
```
每条命令会提示你粘贴值,粘贴后回车。VERIFY_TOKEN 和 MCP_TOKEN 自己生成,例如:
```bash
openssl rand -hex 16
```
本地调试时改用 .dev.vars 文件(不要提交到 Git),格式是 KEY=value 一行一个。
第 8 步:部署
```bash
npx wrangler deploy
```
输出里会出现形如 https://wa-mcp.<你的子域>.workers.dev 的地址,记下来。
第 9 步:回填 Meta 回调地址
回到 Meta 应用 → WhatsApp → Configuration → Webhook,点编辑:
- Callback URL:
https://wa-mcp.<你的子域>.workers.dev/webhook - Verify token:填你刚才设置的
VERIFY_TOKEN
点「验证并保存」。Meta 会立刻对你的 Worker 发一次 GET 请求,只有在第 6 步的代码已经部署上去之后才能通过。验证通过后,在下方订阅字段里勾选 messages。
验证部署是否成功
1. 模拟 Meta 的验证请求
```bash
curl -i "https://wa-mcp.<你的子域>.workers.dev/webhook?hub.mode=subscribe&hub.verify_token=<你的VERIFY_TOKEN>&hub.challenge=12345"
```
预期:HTTP 200,响应体就是 12345。如果返回 403,说明 token 不一致。
2. 验证 MCP 端点鉴权
```bash
curl -i https://wa-mcp.<你的子域>.workers.dev/mcp
```
预期:401 unauthorized。这说明防护生效了。
3. 初始化 MCP 会话
```bash
curl -i https://wa-mcp.<你的子域>.workers.dev/mcp \
-H "Authorization: Bearer <你的MCP_TOKEN>" \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```
预期:200,响应里包含 serverInfo 和 capabilities.tools。响应头里通常会带 mcp-session-id,后续请求需要带上它。protocolVersion 用你的客户端支持的版本,以 MCP 规范当前版本为准。
4. 列出工具
```bash
curl -s https://wa-mcp.<你的子域>.workers.dev/mcp \
-H "Authorization: Bearer <你的MCP_TOKEN>" \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-H "mcp-session-id: <上一步返回的ID>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```
预期:能看到 send_whatsapp_message 和 list_recent_messages 两个工具及其参数说明。
5. 真正发一条消息
```bash
curl -s https://wa-mcp.<你的子域>.workers.dev/mcp \
-H "Authorization: Bearer <你的MCP_TOKEN>" \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-H "mcp-session-id: <会话ID>" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"send_whatsapp_message","arguments":{"to":"8613800000000","text":"来自 MCP 的第一条消息"}}}'
```
预期:手机收到 WhatsApp 消息,响应里是 已发送给 8613800000000。
6. 反向验证:从手机发一条消息给测试号码
然后打开一个终端:
```bash
npx wrangler tail
```
再调用一次 list_recent_messages。预期:日志里出现一条 webhook POST 请求,tools/call 结果里能看到你刚发的消息内容。这一来一回都通了,说明整条链路成立。
7. 接进 MCP 客户端
在你的 MCP 客户端配置里加一项远程服务器(不同客户端字段名不同,以各自文档为准):
```json
{
"mcpServers": {
"whatsapp": {
"type": "http",
"url": "https://wa-mcp.<你的子域>.workers.dev/mcp",
"headers": { "Authorization": "Bearer <你的MCP_TOKEN>" }
}
}
}
```
重启客户端后,在对话里说「帮我看看最近三条 WhatsApp 消息」,智能体应该会调用 list_recent_messages。
常见报错与解决
1. Meta 提示 The callback URL or verify token couldn't be validated
原因:GET 请求没有返回纯文本的 hub.challenge,或者 VERIFY_TOKEN 和你部署时设置的值不一致,也可能代码还没部署就先去点验证了。
解决:先用第 1 条 curl 命令本地复现,确认返回 200 和 challenge,再回 Meta 页面点保存;token 对不上就重设:
```bash
npx wrangler secret put VERIFY_TOKEN
npx wrangler deploy
```
2. {"error":{"message":"Invalid OAuth access token","type":"OAuthException","code":190}}
原因:WHATSAPP_TOKEN 过期。Meta 页面上给的临时令牌有效期很短,隔天就失效。
解决:按官方文档创建 System User 生成长期令牌,然后重新写入:
```bash
npx wrangler secret put WHATSAPP_TOKEN
```
3. {"error":{...,"code":131030}} 或提示收件人不在允许列表
原因:测试号码只能给已加入列表的号码发消息。
解决:到 WhatsApp → API Setup 的收件人列表里把目标号码加进去并完成验证码校验,再重试。正式上线需要把号码迁到正式 WABA 并完成商务验证。
4. 超过 24 小时窗口,报 Re-engagement message 相关错误
原因:用户最后一次发消息已超过 24 小时,此时只能发模板消息。
解决:在 Meta 后台创建并审核一个消息模板,把 type 改成 template 并传模板名与参数;或者先等用户主动发一条消息再回复。
5. 部署报错 You must use "new_sqlite_classes" 或 Durable Object 类找不到
原因:migrations 里写成了 new_classes,或 class_name 和代码里 export class 的名字不一致。
解决:改成 new_sqlite_classes,确认两个类名完全匹配后重新部署:
```bash
npx wrangler deploy
```
注意已部署过的迁移 tag 不能修改内容,需要新增 {"tag": "v2", ...}。
6. Webhook 一直返回 401 invalid signature
原因:先 JSON.parse 再 JSON.stringify 去算签名,字段顺序和空白被改掉,HMAC 必然对不上;或者 APP_SECRET 填错。
解决:坚持用 const raw = await request.text() 拿原始文本算签名,并确认 secret 是「设置 → 基本」里的 App Secret,不是令牌:
```bash
npx wrangler secret put APP_SECRET
```
7. 日志出现 Worker exceeded CPU time limit,Meta 反复重推同一条消息
原因:在 webhook 里同步调用了发送接口或做了耗时计算。
解决:webhook 只做校验和落库,立即返回 200,耗时操作放进 ctx.waitUntil();同时确认第 4 步的消息去重逻辑生效。
后续维护
备份
- 代码放 Git,
wrangler.jsonc、package.json、package-lock.json一起提交,保证能重建。 - 密钥单独记一份清单(哪个平台、什么时候生成、什么时候到期),只存在密码管理器里,不要写进仓库。
- Durable Object 里的会话数据是「最近 100 条 + 去重 ID」的滚动窗口,本身不是长期归档。需要长期留存,就在
record()里加一段转发到 D1 或对象存储的逻辑。
升级
- 升级依赖:
npm outdated看有哪些落后,再npm i agents@latest @modelcontextprotocol/sdk@latest,升级后先npx wrangler dev本地跑一遍再部署。 - 升级 wrangler:
npm i -D wrangler@latest。wrangler 大版本变更时留意配置字段的改名,参考官方迁移说明。 compatibility_date是行为开关,不要随手改。要改就单独提一次部署,改完观察一天日志再继续。- Graph API 版本会定期下线旧版本,
GRAPH_VERSION建议每季度看一眼官方文档,在废弃前升级。
日志与监控
- 保持
observability.enabled = true,在 Cloudflare 面板里能按请求查日志和异常。 - 排查线上问题时用
npx wrangler tail,可以加--format pretty让输出更好读。 - 加一个定时健康检查:外部监控每分钟请求一次
https://<你的域名>/,返回非 200 就告警。这个路径不消耗 Durable Object。 - 关注三类指标:请求数(是否接近免费额度)、Durable Object 存储用量、Graph API 的错误码分布。前两个在 Cloudflare 面板看,第三个靠日志里统计
code字段。 - 给令牌到期设日历提醒。长期令牌也可能因为改密码、权限变更而失效,收到
code:190就当故障处理。 - 用户量上来后,把
/mcp从单一 Bearer Token 换成按用户区分的密钥,或直接用 Cloudflare Access 在前面加一层身份验证,避免一个 token 泄露就全暴露。
