跳到主内容
快讯直播
AI智模界
教程

用 Cloudflare Workers 免费部署 WhatsApp MCP:让智能体收发消息

适用场景

你希望自己的智能体(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_IDMeta 应用 → WhatsApp → API Setup发送消息时拼进 Graph API 地址
WHATSAPP_TOKEN同上,或 System User 生成的长期令牌调用发送接口的凭证
APP_SECRETMeta 应用 → 设置 → 基本校验回调签名,防止伪造请求
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 泄露就全暴露。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。