@codespar/mcp-whatsapp-cloud 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -0
- package/dist/index.js +427 -0
- package/package.json +33 -0
- package/server.json +51 -0
- package/src/index.ts +427 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @codespar/mcp-whatsapp-cloud
|
|
2
|
+
|
|
3
|
+
MCP server for the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) — Meta's official WhatsApp Business API, self-hosted on Meta infrastructure.
|
|
4
|
+
|
|
5
|
+
**Direct Meta integration.** No middleman, no provider markup. For merchants with an approved WhatsApp Business Account (WABA) who want Meta-direct pricing and full control over conversation, pricing category, and template lifecycle.
|
|
6
|
+
|
|
7
|
+
## WhatsApp servers in this catalog
|
|
8
|
+
|
|
9
|
+
| Server | What it is | When to pick it |
|
|
10
|
+
|--------|------------|-----------------|
|
|
11
|
+
| **whatsapp-cloud** (this) | Direct Meta Cloud API | Large merchants with approved WABA; lower cost at scale, no intermediary fees |
|
|
12
|
+
| z-api | Wrapper on top of Meta Cloud | Easy onboarding, instant QR-pair, extra helpers |
|
|
13
|
+
| evolution-api | Open-source wrapper | Self-hosted, community-driven |
|
|
14
|
+
| take-blip | Brazilian BSP wrapper | Enterprise Brazil, CCaaS features |
|
|
15
|
+
| zenvia | Brazilian BSP wrapper | Brazil omnichannel (SMS + WhatsApp) |
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Purpose |
|
|
20
|
+
|------|---------|
|
|
21
|
+
| `send_text_message` | Send a plain text message |
|
|
22
|
+
| `send_template_message` | Send an approved template (required for business-initiated > 24h) |
|
|
23
|
+
| `send_media_message` | Send image, video, document, or audio (link or media_id) |
|
|
24
|
+
| `send_interactive_message` | Send reply buttons or list |
|
|
25
|
+
| `send_location_message` | Send a latitude/longitude pin |
|
|
26
|
+
| `mark_message_as_read` | Mark an inbound message as read |
|
|
27
|
+
| `upload_media` | Upload a file and get a reusable media_id (multipart) |
|
|
28
|
+
| `retrieve_media_url` | Resolve a media_id to a downloadable URL |
|
|
29
|
+
| `delete_media` | Delete an uploaded media asset |
|
|
30
|
+
| `list_templates` | List templates on the WABA |
|
|
31
|
+
| `create_template` | Submit a new template for Meta review |
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @codespar/mcp-whatsapp-cloud
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Environment
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
WHATSAPP_ACCESS_TOKEN="EAAG..." # required (secret) — Meta system-user token
|
|
43
|
+
WHATSAPP_PHONE_NUMBER_ID="1234567890" # required — WABA phone number id
|
|
44
|
+
WHATSAPP_BUSINESS_ACCOUNT_ID="9876543210" # required — WABA id (for templates)
|
|
45
|
+
WHATSAPP_GRAPH_VERSION="v21.0" # optional — Meta bumps quarterly
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Authentication
|
|
49
|
+
|
|
50
|
+
Bearer token against the Graph API. Use a **permanent system-user token** from Meta Business Manager — user access tokens expire and will break production.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Authorization: Bearer <WHATSAPP_ACCESS_TOKEN>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Messaging rules (important)
|
|
57
|
+
|
|
58
|
+
- **Customer-service window** — You can freely send any message type for 24h after the user last messaged you.
|
|
59
|
+
- **Business-initiated** — Outside that window you must send an approved **template**. Use `send_template_message` with a name + language + components.
|
|
60
|
+
- **Templates** — Create with `create_template`, wait for Meta approval (minutes to hours), then use.
|
|
61
|
+
- Phone numbers are **E.164 without the leading `+`** (e.g. `5511999999999`).
|
|
62
|
+
|
|
63
|
+
## Media
|
|
64
|
+
|
|
65
|
+
Two paths:
|
|
66
|
+
|
|
67
|
+
1. **Public URL** — pass `link` to `send_media_message`. Fastest, but Meta fetches on every send.
|
|
68
|
+
2. **Uploaded media_id** — call `upload_media` once, reuse `id` on subsequent sends. Recommended for catalog assets.
|
|
69
|
+
|
|
70
|
+
Uploaded media expires after ~30 days.
|
|
71
|
+
|
|
72
|
+
## Interactive messages
|
|
73
|
+
|
|
74
|
+
`send_interactive_message` expects a fully-formed `interactive` object. Example button payload:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"type": "button",
|
|
79
|
+
"body": { "text": "Confirma seu pedido?" },
|
|
80
|
+
"action": {
|
|
81
|
+
"buttons": [
|
|
82
|
+
{ "type": "reply", "reply": { "id": "confirm", "title": "Confirmar" } },
|
|
83
|
+
{ "type": "reply", "reply": { "id": "cancel", "title": "Cancelar" } }
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See the [Cloud API interactive reference](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates) for list payloads.
|
|
90
|
+
|
|
91
|
+
## Run
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# stdio (default — for Claude Desktop, Cursor, etc)
|
|
95
|
+
npx @codespar/mcp-whatsapp-cloud
|
|
96
|
+
|
|
97
|
+
# HTTP (for server-to-server)
|
|
98
|
+
MCP_HTTP=true MCP_PORT=3000 npx @codespar/mcp-whatsapp-cloud
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP Server for WhatsApp Cloud API — Meta's official Business API.
|
|
4
|
+
*
|
|
5
|
+
* Direct integration with Meta Graph API. No middleman. Distinct from the
|
|
6
|
+
* wrapper providers in the catalog (Z-API, Evolution, Take Blip, Zenvia) which
|
|
7
|
+
* all sit on top of this. Target: merchants with an approved WhatsApp Business
|
|
8
|
+
* Account (WABA) who want Meta-direct pricing and full control.
|
|
9
|
+
*
|
|
10
|
+
* Tools (11):
|
|
11
|
+
* send_text_message — simple text message
|
|
12
|
+
* send_template_message — approved template (required for 24h+ business-initiated)
|
|
13
|
+
* send_media_message — image, video, document, or audio
|
|
14
|
+
* send_interactive_message — buttons or list
|
|
15
|
+
* send_location_message — latitude/longitude pin
|
|
16
|
+
* mark_message_as_read — mark an incoming message as read
|
|
17
|
+
* upload_media — upload a file and get a media_id (multipart)
|
|
18
|
+
* retrieve_media_url — resolve a media_id to a downloadable URL
|
|
19
|
+
* delete_media — delete an uploaded media asset
|
|
20
|
+
* list_templates — list templates on the WABA
|
|
21
|
+
* create_template — submit a new template for Meta review
|
|
22
|
+
*
|
|
23
|
+
* Authentication
|
|
24
|
+
* Bearer token (permanent system-user access token).
|
|
25
|
+
* Authorization: Bearer <WHATSAPP_ACCESS_TOKEN>
|
|
26
|
+
*
|
|
27
|
+
* API surface
|
|
28
|
+
* Graph API base: https://graph.facebook.com/{version}
|
|
29
|
+
* Message + media endpoints are scoped to a phone_number_id.
|
|
30
|
+
* Template endpoints are scoped to a business_account_id.
|
|
31
|
+
*
|
|
32
|
+
* Environment
|
|
33
|
+
* WHATSAPP_ACCESS_TOKEN required — Meta system-user token (secret)
|
|
34
|
+
* WHATSAPP_PHONE_NUMBER_ID required — WABA phone number id
|
|
35
|
+
* WHATSAPP_BUSINESS_ACCOUNT_ID required — WABA id (for template management)
|
|
36
|
+
* WHATSAPP_GRAPH_VERSION optional — default v21.0
|
|
37
|
+
*
|
|
38
|
+
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api
|
|
39
|
+
*/
|
|
40
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
41
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
42
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
43
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
44
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
45
|
+
const ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN || "";
|
|
46
|
+
const PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID || "";
|
|
47
|
+
const BUSINESS_ACCOUNT_ID = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID || "";
|
|
48
|
+
const GRAPH_VERSION = process.env.WHATSAPP_GRAPH_VERSION || "v21.0";
|
|
49
|
+
const BASE_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
|
|
50
|
+
async function whatsappRequest(method, path, body, opts = {}) {
|
|
51
|
+
const url = `${BASE_URL}${path}`;
|
|
52
|
+
const headers = {
|
|
53
|
+
Authorization: `Bearer ${ACCESS_TOKEN}`,
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
};
|
|
56
|
+
let payload;
|
|
57
|
+
if (body !== undefined && body !== null) {
|
|
58
|
+
if (opts.multipart) {
|
|
59
|
+
const form = new FormData();
|
|
60
|
+
const b = body;
|
|
61
|
+
for (const [k, v] of Object.entries(b)) {
|
|
62
|
+
if (v === undefined || v === null)
|
|
63
|
+
continue;
|
|
64
|
+
if (k === "file" && typeof v === "object" && v !== null && "data" in v) {
|
|
65
|
+
const f = v;
|
|
66
|
+
const bytes = Buffer.from(f.data, "base64");
|
|
67
|
+
const blob = new Blob([bytes], { type: f.contentType || "application/octet-stream" });
|
|
68
|
+
form.append("file", blob, f.filename || "upload.bin");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
form.append(k, String(v));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
payload = form;
|
|
75
|
+
// fetch sets multipart boundary automatically
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
headers["Content-Type"] = "application/json";
|
|
79
|
+
payload = JSON.stringify(body);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const res = await fetch(url, { method, headers, body: payload });
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`WhatsApp Cloud API ${res.status}: ${await res.text()}`);
|
|
85
|
+
}
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
return text ? JSON.parse(text) : {};
|
|
88
|
+
}
|
|
89
|
+
const server = new Server({ name: "mcp-whatsapp-cloud", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
90
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
91
|
+
tools: [
|
|
92
|
+
{
|
|
93
|
+
name: "send_text_message",
|
|
94
|
+
description: "Send a plain text message. For business-initiated conversations outside the 24h customer-service window, use send_template_message instead.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
to: { type: "string", description: "Recipient phone number in E.164 without + (e.g. 5511999999999)" },
|
|
99
|
+
body: { type: "string", description: "Message text (UTF-8, up to 4096 chars)" },
|
|
100
|
+
preview_url: { type: "boolean", description: "Enable URL preview for links in body (default false)" },
|
|
101
|
+
},
|
|
102
|
+
required: ["to", "body"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "send_template_message",
|
|
107
|
+
description: "Send an approved message template. Required for business-initiated conversations. Templates must be pre-approved by Meta via create_template.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
112
|
+
template_name: { type: "string", description: "Exact name of the approved template" },
|
|
113
|
+
language_code: { type: "string", description: "BCP-47 language tag (e.g. en_US, pt_BR, es_MX)" },
|
|
114
|
+
components: {
|
|
115
|
+
type: "array",
|
|
116
|
+
description: "Template component parameters (header, body, button). See Cloud API docs for shape.",
|
|
117
|
+
items: { type: "object" },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ["to", "template_name", "language_code"],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "send_media_message",
|
|
125
|
+
description: "Send an image, video, document, or audio. Supply either `link` (public URL) or `id` (media_id from upload_media).",
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
130
|
+
media_type: { type: "string", enum: ["image", "video", "document", "audio"], description: "Kind of media" },
|
|
131
|
+
link: { type: "string", description: "Public HTTPS URL of the media. Use this OR id." },
|
|
132
|
+
id: { type: "string", description: "Uploaded media_id from upload_media. Use this OR link." },
|
|
133
|
+
caption: { type: "string", description: "Optional caption (image, video, document only)" },
|
|
134
|
+
filename: { type: "string", description: "Optional filename (document only)" },
|
|
135
|
+
},
|
|
136
|
+
required: ["to", "media_type"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "send_interactive_message",
|
|
141
|
+
description: "Send an interactive message (reply buttons or list). Supply a fully-formed `interactive` object per Cloud API spec.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
146
|
+
interactive: {
|
|
147
|
+
type: "object",
|
|
148
|
+
description: "Interactive payload. For buttons: { type: 'button', body: { text }, action: { buttons: [...] } }. For list: { type: 'list', body: { text }, action: { button, sections } }.",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ["to", "interactive"],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "send_location_message",
|
|
156
|
+
description: "Send a location pin with latitude/longitude and optional name/address.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
161
|
+
latitude: { type: "number", description: "Latitude" },
|
|
162
|
+
longitude: { type: "number", description: "Longitude" },
|
|
163
|
+
name: { type: "string", description: "Optional location name" },
|
|
164
|
+
address: { type: "string", description: "Optional location address" },
|
|
165
|
+
},
|
|
166
|
+
required: ["to", "latitude", "longitude"],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "mark_message_as_read",
|
|
171
|
+
description: "Mark an incoming message as read so the sender sees the blue double-check. Uses the wamid from the inbound webhook.",
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
message_id: { type: "string", description: "Inbound message id (wamid.XXX) from the webhook" },
|
|
176
|
+
},
|
|
177
|
+
required: ["message_id"],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "upload_media",
|
|
182
|
+
description: "Upload a media file and get back a media_id reusable in send_media_message. Multipart POST to /{phone_number_id}/media.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
file_base64: { type: "string", description: "File contents, base64-encoded" },
|
|
187
|
+
filename: { type: "string", description: "Filename (used for display and extension inference)" },
|
|
188
|
+
mime_type: { type: "string", description: "MIME type (e.g. image/jpeg, video/mp4, application/pdf, audio/ogg)" },
|
|
189
|
+
},
|
|
190
|
+
required: ["file_base64", "filename", "mime_type"],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "retrieve_media_url",
|
|
195
|
+
description: "Resolve a media_id to a short-lived downloadable URL. The URL itself still requires the Bearer token to fetch.",
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
media_id: { type: "string", description: "Media id returned by upload_media or received in a webhook" },
|
|
200
|
+
},
|
|
201
|
+
required: ["media_id"],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "delete_media",
|
|
206
|
+
description: "Delete an uploaded media asset by id.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
media_id: { type: "string", description: "Media id to delete" },
|
|
211
|
+
},
|
|
212
|
+
required: ["media_id"],
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "list_templates",
|
|
217
|
+
description: "List message templates on the WhatsApp Business Account. Supports optional paging and name filter.",
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
limit: { type: "number", description: "Max templates per page (default 25)" },
|
|
222
|
+
name: { type: "string", description: "Filter by exact template name" },
|
|
223
|
+
status: { type: "string", description: "Filter by status (APPROVED, PENDING, REJECTED, PAUSED, DISABLED)" },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "create_template",
|
|
229
|
+
description: "Submit a new template for Meta review. Templates become usable only after approval (usually minutes to hours).",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
name: { type: "string", description: "Template name (lowercase, underscores, unique on WABA)" },
|
|
234
|
+
language: { type: "string", description: "BCP-47 language tag (e.g. en_US, pt_BR)" },
|
|
235
|
+
category: { type: "string", enum: ["AUTHENTICATION", "MARKETING", "UTILITY"], description: "Template category" },
|
|
236
|
+
components: {
|
|
237
|
+
type: "array",
|
|
238
|
+
description: "Array of components: HEADER, BODY, FOOTER, BUTTONS. See Cloud API template docs.",
|
|
239
|
+
items: { type: "object" },
|
|
240
|
+
},
|
|
241
|
+
allow_category_change: { type: "boolean", description: "Let Meta re-categorize if needed (default true recommended)" },
|
|
242
|
+
},
|
|
243
|
+
required: ["name", "language", "category", "components"],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
}));
|
|
248
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
249
|
+
const { name, arguments: args } = request.params;
|
|
250
|
+
const a = (args ?? {});
|
|
251
|
+
try {
|
|
252
|
+
switch (name) {
|
|
253
|
+
case "send_text_message": {
|
|
254
|
+
const body = {
|
|
255
|
+
messaging_product: "whatsapp",
|
|
256
|
+
recipient_type: "individual",
|
|
257
|
+
to: a.to,
|
|
258
|
+
type: "text",
|
|
259
|
+
text: {
|
|
260
|
+
body: a.body,
|
|
261
|
+
preview_url: a.preview_url ?? false,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
265
|
+
}
|
|
266
|
+
case "send_template_message": {
|
|
267
|
+
const template = {
|
|
268
|
+
name: a.template_name,
|
|
269
|
+
language: { code: a.language_code },
|
|
270
|
+
};
|
|
271
|
+
if (a.components)
|
|
272
|
+
template.components = a.components;
|
|
273
|
+
const body = {
|
|
274
|
+
messaging_product: "whatsapp",
|
|
275
|
+
to: a.to,
|
|
276
|
+
type: "template",
|
|
277
|
+
template,
|
|
278
|
+
};
|
|
279
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
280
|
+
}
|
|
281
|
+
case "send_media_message": {
|
|
282
|
+
const mediaType = String(a.media_type);
|
|
283
|
+
const mediaObj = {};
|
|
284
|
+
if (a.id)
|
|
285
|
+
mediaObj.id = a.id;
|
|
286
|
+
if (a.link)
|
|
287
|
+
mediaObj.link = a.link;
|
|
288
|
+
if (a.caption && mediaType !== "audio")
|
|
289
|
+
mediaObj.caption = a.caption;
|
|
290
|
+
if (a.filename && mediaType === "document")
|
|
291
|
+
mediaObj.filename = a.filename;
|
|
292
|
+
const body = {
|
|
293
|
+
messaging_product: "whatsapp",
|
|
294
|
+
recipient_type: "individual",
|
|
295
|
+
to: a.to,
|
|
296
|
+
type: mediaType,
|
|
297
|
+
[mediaType]: mediaObj,
|
|
298
|
+
};
|
|
299
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
300
|
+
}
|
|
301
|
+
case "send_interactive_message": {
|
|
302
|
+
const body = {
|
|
303
|
+
messaging_product: "whatsapp",
|
|
304
|
+
recipient_type: "individual",
|
|
305
|
+
to: a.to,
|
|
306
|
+
type: "interactive",
|
|
307
|
+
interactive: a.interactive,
|
|
308
|
+
};
|
|
309
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
310
|
+
}
|
|
311
|
+
case "send_location_message": {
|
|
312
|
+
const location = {
|
|
313
|
+
latitude: a.latitude,
|
|
314
|
+
longitude: a.longitude,
|
|
315
|
+
};
|
|
316
|
+
if (a.name)
|
|
317
|
+
location.name = a.name;
|
|
318
|
+
if (a.address)
|
|
319
|
+
location.address = a.address;
|
|
320
|
+
const body = {
|
|
321
|
+
messaging_product: "whatsapp",
|
|
322
|
+
recipient_type: "individual",
|
|
323
|
+
to: a.to,
|
|
324
|
+
type: "location",
|
|
325
|
+
location,
|
|
326
|
+
};
|
|
327
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
328
|
+
}
|
|
329
|
+
case "mark_message_as_read": {
|
|
330
|
+
const body = {
|
|
331
|
+
messaging_product: "whatsapp",
|
|
332
|
+
status: "read",
|
|
333
|
+
message_id: a.message_id,
|
|
334
|
+
};
|
|
335
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
336
|
+
}
|
|
337
|
+
case "upload_media": {
|
|
338
|
+
const body = {
|
|
339
|
+
messaging_product: "whatsapp",
|
|
340
|
+
type: a.mime_type,
|
|
341
|
+
file: {
|
|
342
|
+
data: a.file_base64,
|
|
343
|
+
filename: a.filename,
|
|
344
|
+
contentType: a.mime_type,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/media`, body, { multipart: true }), null, 2) }] };
|
|
348
|
+
}
|
|
349
|
+
case "retrieve_media_url":
|
|
350
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("GET", `/${a.media_id}`), null, 2) }] };
|
|
351
|
+
case "delete_media":
|
|
352
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("DELETE", `/${a.media_id}`), null, 2) }] };
|
|
353
|
+
case "list_templates": {
|
|
354
|
+
const q = new URLSearchParams();
|
|
355
|
+
if (a.limit !== undefined)
|
|
356
|
+
q.set("limit", String(a.limit));
|
|
357
|
+
if (a.name)
|
|
358
|
+
q.set("name", String(a.name));
|
|
359
|
+
if (a.status)
|
|
360
|
+
q.set("status", String(a.status));
|
|
361
|
+
const qs = q.toString();
|
|
362
|
+
const path = `/${BUSINESS_ACCOUNT_ID}/message_templates${qs ? `?${qs}` : ""}`;
|
|
363
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("GET", path), null, 2) }] };
|
|
364
|
+
}
|
|
365
|
+
case "create_template": {
|
|
366
|
+
const body = {
|
|
367
|
+
name: a.name,
|
|
368
|
+
language: a.language,
|
|
369
|
+
category: a.category,
|
|
370
|
+
components: a.components,
|
|
371
|
+
};
|
|
372
|
+
if (a.allow_category_change !== undefined)
|
|
373
|
+
body.allow_category_change = a.allow_category_change;
|
|
374
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${BUSINESS_ACCOUNT_ID}/message_templates`, body), null, 2) }] };
|
|
375
|
+
}
|
|
376
|
+
default:
|
|
377
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
async function main() {
|
|
385
|
+
if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
|
|
386
|
+
const { default: express } = await import("express");
|
|
387
|
+
const { randomUUID } = await import("node:crypto");
|
|
388
|
+
const app = express();
|
|
389
|
+
app.use(express.json());
|
|
390
|
+
const transports = new Map();
|
|
391
|
+
app.get("/health", (_req, res) => res.json({ status: "ok", sessions: transports.size }));
|
|
392
|
+
app.post("/mcp", async (req, res) => {
|
|
393
|
+
const sid = req.headers["mcp-session-id"];
|
|
394
|
+
if (sid && transports.has(sid)) {
|
|
395
|
+
await transports.get(sid).handleRequest(req, res, req.body);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!sid && isInitializeRequest(req.body)) {
|
|
399
|
+
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
|
|
400
|
+
t.onclose = () => { if (t.sessionId)
|
|
401
|
+
transports.delete(t.sessionId); };
|
|
402
|
+
const s = new Server({ name: "mcp-whatsapp-cloud", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
403
|
+
server._requestHandlers.forEach((v, k) => s._requestHandlers.set(k, v));
|
|
404
|
+
server._notificationHandlers?.forEach((v, k) => s._notificationHandlers.set(k, v));
|
|
405
|
+
await s.connect(t);
|
|
406
|
+
await t.handleRequest(req, res, req.body);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
|
|
410
|
+
});
|
|
411
|
+
app.get("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
|
|
412
|
+
await transports.get(sid).handleRequest(req, res);
|
|
413
|
+
else
|
|
414
|
+
res.status(400).send("Invalid session"); });
|
|
415
|
+
app.delete("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
|
|
416
|
+
await transports.get(sid).handleRequest(req, res);
|
|
417
|
+
else
|
|
418
|
+
res.status(400).send("Invalid session"); });
|
|
419
|
+
const port = Number(process.env.MCP_PORT) || 3000;
|
|
420
|
+
app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
const transport = new StdioServerTransport();
|
|
424
|
+
await server.connect(transport);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codespar/mcp-whatsapp-cloud",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for WhatsApp Cloud API (Meta direct) — official Graph API integration for messages, media, and templates",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-whatsapp-cloud": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^25.5.0",
|
|
19
|
+
"typescript": "^5.8.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"whatsapp",
|
|
25
|
+
"whatsapp-cloud",
|
|
26
|
+
"meta",
|
|
27
|
+
"graph-api",
|
|
28
|
+
"messaging",
|
|
29
|
+
"waba",
|
|
30
|
+
"business"
|
|
31
|
+
],
|
|
32
|
+
"mcpName": "io.github.codespar/mcp-whatsapp-cloud"
|
|
33
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.codespar/mcp-whatsapp-cloud",
|
|
4
|
+
"description": "MCP server for WhatsApp Cloud API (Meta direct) — official Graph API integration for messages, media, and templates",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/codespar/mcp-dev-brasil",
|
|
7
|
+
"source": "github",
|
|
8
|
+
"subfolder": "packages/communication/whatsapp-cloud"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registryType": "npm",
|
|
14
|
+
"identifier": "@codespar/mcp-whatsapp-cloud",
|
|
15
|
+
"version": "0.1.0",
|
|
16
|
+
"transport": {
|
|
17
|
+
"type": "stdio"
|
|
18
|
+
},
|
|
19
|
+
"environmentVariables": [
|
|
20
|
+
{
|
|
21
|
+
"name": "WHATSAPP_ACCESS_TOKEN",
|
|
22
|
+
"description": "Meta system-user access token (permanent). Used as Bearer token for Graph API.",
|
|
23
|
+
"isRequired": true,
|
|
24
|
+
"format": "string",
|
|
25
|
+
"isSecret": true
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "WHATSAPP_PHONE_NUMBER_ID",
|
|
29
|
+
"description": "WhatsApp Business phone number ID registered with Meta.",
|
|
30
|
+
"isRequired": true,
|
|
31
|
+
"format": "string",
|
|
32
|
+
"isSecret": false
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
|
36
|
+
"description": "WhatsApp Business Account (WABA) ID. Used for template management.",
|
|
37
|
+
"isRequired": true,
|
|
38
|
+
"format": "string",
|
|
39
|
+
"isSecret": false
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "WHATSAPP_GRAPH_VERSION",
|
|
43
|
+
"description": "Graph API version. Defaults to v21.0. Meta bumps quarterly.",
|
|
44
|
+
"isRequired": false,
|
|
45
|
+
"format": "string",
|
|
46
|
+
"isSecret": false
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP Server for WhatsApp Cloud API — Meta's official Business API.
|
|
5
|
+
*
|
|
6
|
+
* Direct integration with Meta Graph API. No middleman. Distinct from the
|
|
7
|
+
* wrapper providers in the catalog (Z-API, Evolution, Take Blip, Zenvia) which
|
|
8
|
+
* all sit on top of this. Target: merchants with an approved WhatsApp Business
|
|
9
|
+
* Account (WABA) who want Meta-direct pricing and full control.
|
|
10
|
+
*
|
|
11
|
+
* Tools (11):
|
|
12
|
+
* send_text_message — simple text message
|
|
13
|
+
* send_template_message — approved template (required for 24h+ business-initiated)
|
|
14
|
+
* send_media_message — image, video, document, or audio
|
|
15
|
+
* send_interactive_message — buttons or list
|
|
16
|
+
* send_location_message — latitude/longitude pin
|
|
17
|
+
* mark_message_as_read — mark an incoming message as read
|
|
18
|
+
* upload_media — upload a file and get a media_id (multipart)
|
|
19
|
+
* retrieve_media_url — resolve a media_id to a downloadable URL
|
|
20
|
+
* delete_media — delete an uploaded media asset
|
|
21
|
+
* list_templates — list templates on the WABA
|
|
22
|
+
* create_template — submit a new template for Meta review
|
|
23
|
+
*
|
|
24
|
+
* Authentication
|
|
25
|
+
* Bearer token (permanent system-user access token).
|
|
26
|
+
* Authorization: Bearer <WHATSAPP_ACCESS_TOKEN>
|
|
27
|
+
*
|
|
28
|
+
* API surface
|
|
29
|
+
* Graph API base: https://graph.facebook.com/{version}
|
|
30
|
+
* Message + media endpoints are scoped to a phone_number_id.
|
|
31
|
+
* Template endpoints are scoped to a business_account_id.
|
|
32
|
+
*
|
|
33
|
+
* Environment
|
|
34
|
+
* WHATSAPP_ACCESS_TOKEN required — Meta system-user token (secret)
|
|
35
|
+
* WHATSAPP_PHONE_NUMBER_ID required — WABA phone number id
|
|
36
|
+
* WHATSAPP_BUSINESS_ACCOUNT_ID required — WABA id (for template management)
|
|
37
|
+
* WHATSAPP_GRAPH_VERSION optional — default v21.0
|
|
38
|
+
*
|
|
39
|
+
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
43
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
44
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
45
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
46
|
+
import {
|
|
47
|
+
CallToolRequestSchema,
|
|
48
|
+
ListToolsRequestSchema,
|
|
49
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
50
|
+
|
|
51
|
+
const ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN || "";
|
|
52
|
+
const PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID || "";
|
|
53
|
+
const BUSINESS_ACCOUNT_ID = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID || "";
|
|
54
|
+
const GRAPH_VERSION = process.env.WHATSAPP_GRAPH_VERSION || "v21.0";
|
|
55
|
+
const BASE_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
|
|
56
|
+
|
|
57
|
+
interface RequestOptions {
|
|
58
|
+
multipart?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function whatsappRequest(
|
|
62
|
+
method: string,
|
|
63
|
+
path: string,
|
|
64
|
+
body?: unknown,
|
|
65
|
+
opts: RequestOptions = {}
|
|
66
|
+
): Promise<unknown> {
|
|
67
|
+
const url = `${BASE_URL}${path}`;
|
|
68
|
+
const headers: Record<string, string> = {
|
|
69
|
+
Authorization: `Bearer ${ACCESS_TOKEN}`,
|
|
70
|
+
Accept: "application/json",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let payload: BodyInit | undefined;
|
|
74
|
+
if (body !== undefined && body !== null) {
|
|
75
|
+
if (opts.multipart) {
|
|
76
|
+
const form = new FormData();
|
|
77
|
+
const b = body as Record<string, unknown>;
|
|
78
|
+
for (const [k, v] of Object.entries(b)) {
|
|
79
|
+
if (v === undefined || v === null) continue;
|
|
80
|
+
if (k === "file" && typeof v === "object" && v !== null && "data" in (v as Record<string, unknown>)) {
|
|
81
|
+
const f = v as { data: string; filename?: string; contentType?: string };
|
|
82
|
+
const bytes = Buffer.from(f.data, "base64");
|
|
83
|
+
const blob = new Blob([bytes], { type: f.contentType || "application/octet-stream" });
|
|
84
|
+
form.append("file", blob, f.filename || "upload.bin");
|
|
85
|
+
} else {
|
|
86
|
+
form.append(k, String(v));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
payload = form;
|
|
90
|
+
// fetch sets multipart boundary automatically
|
|
91
|
+
} else {
|
|
92
|
+
headers["Content-Type"] = "application/json";
|
|
93
|
+
payload = JSON.stringify(body);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const res = await fetch(url, { method, headers, body: payload });
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
throw new Error(`WhatsApp Cloud API ${res.status}: ${await res.text()}`);
|
|
100
|
+
}
|
|
101
|
+
const text = await res.text();
|
|
102
|
+
return text ? JSON.parse(text) : {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const server = new Server(
|
|
106
|
+
{ name: "mcp-whatsapp-cloud", version: "0.1.0" },
|
|
107
|
+
{ capabilities: { tools: {} } }
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
111
|
+
tools: [
|
|
112
|
+
{
|
|
113
|
+
name: "send_text_message",
|
|
114
|
+
description: "Send a plain text message. For business-initiated conversations outside the 24h customer-service window, use send_template_message instead.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
to: { type: "string", description: "Recipient phone number in E.164 without + (e.g. 5511999999999)" },
|
|
119
|
+
body: { type: "string", description: "Message text (UTF-8, up to 4096 chars)" },
|
|
120
|
+
preview_url: { type: "boolean", description: "Enable URL preview for links in body (default false)" },
|
|
121
|
+
},
|
|
122
|
+
required: ["to", "body"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "send_template_message",
|
|
127
|
+
description: "Send an approved message template. Required for business-initiated conversations. Templates must be pre-approved by Meta via create_template.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
132
|
+
template_name: { type: "string", description: "Exact name of the approved template" },
|
|
133
|
+
language_code: { type: "string", description: "BCP-47 language tag (e.g. en_US, pt_BR, es_MX)" },
|
|
134
|
+
components: {
|
|
135
|
+
type: "array",
|
|
136
|
+
description: "Template component parameters (header, body, button). See Cloud API docs for shape.",
|
|
137
|
+
items: { type: "object" },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: ["to", "template_name", "language_code"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "send_media_message",
|
|
145
|
+
description: "Send an image, video, document, or audio. Supply either `link` (public URL) or `id` (media_id from upload_media).",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
150
|
+
media_type: { type: "string", enum: ["image", "video", "document", "audio"], description: "Kind of media" },
|
|
151
|
+
link: { type: "string", description: "Public HTTPS URL of the media. Use this OR id." },
|
|
152
|
+
id: { type: "string", description: "Uploaded media_id from upload_media. Use this OR link." },
|
|
153
|
+
caption: { type: "string", description: "Optional caption (image, video, document only)" },
|
|
154
|
+
filename: { type: "string", description: "Optional filename (document only)" },
|
|
155
|
+
},
|
|
156
|
+
required: ["to", "media_type"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "send_interactive_message",
|
|
161
|
+
description: "Send an interactive message (reply buttons or list). Supply a fully-formed `interactive` object per Cloud API spec.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
166
|
+
interactive: {
|
|
167
|
+
type: "object",
|
|
168
|
+
description: "Interactive payload. For buttons: { type: 'button', body: { text }, action: { buttons: [...] } }. For list: { type: 'list', body: { text }, action: { button, sections } }.",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
required: ["to", "interactive"],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "send_location_message",
|
|
176
|
+
description: "Send a location pin with latitude/longitude and optional name/address.",
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
to: { type: "string", description: "Recipient phone number in E.164 without +" },
|
|
181
|
+
latitude: { type: "number", description: "Latitude" },
|
|
182
|
+
longitude: { type: "number", description: "Longitude" },
|
|
183
|
+
name: { type: "string", description: "Optional location name" },
|
|
184
|
+
address: { type: "string", description: "Optional location address" },
|
|
185
|
+
},
|
|
186
|
+
required: ["to", "latitude", "longitude"],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "mark_message_as_read",
|
|
191
|
+
description: "Mark an incoming message as read so the sender sees the blue double-check. Uses the wamid from the inbound webhook.",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {
|
|
195
|
+
message_id: { type: "string", description: "Inbound message id (wamid.XXX) from the webhook" },
|
|
196
|
+
},
|
|
197
|
+
required: ["message_id"],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "upload_media",
|
|
202
|
+
description: "Upload a media file and get back a media_id reusable in send_media_message. Multipart POST to /{phone_number_id}/media.",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: {
|
|
206
|
+
file_base64: { type: "string", description: "File contents, base64-encoded" },
|
|
207
|
+
filename: { type: "string", description: "Filename (used for display and extension inference)" },
|
|
208
|
+
mime_type: { type: "string", description: "MIME type (e.g. image/jpeg, video/mp4, application/pdf, audio/ogg)" },
|
|
209
|
+
},
|
|
210
|
+
required: ["file_base64", "filename", "mime_type"],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "retrieve_media_url",
|
|
215
|
+
description: "Resolve a media_id to a short-lived downloadable URL. The URL itself still requires the Bearer token to fetch.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
media_id: { type: "string", description: "Media id returned by upload_media or received in a webhook" },
|
|
220
|
+
},
|
|
221
|
+
required: ["media_id"],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "delete_media",
|
|
226
|
+
description: "Delete an uploaded media asset by id.",
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: "object",
|
|
229
|
+
properties: {
|
|
230
|
+
media_id: { type: "string", description: "Media id to delete" },
|
|
231
|
+
},
|
|
232
|
+
required: ["media_id"],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "list_templates",
|
|
237
|
+
description: "List message templates on the WhatsApp Business Account. Supports optional paging and name filter.",
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
limit: { type: "number", description: "Max templates per page (default 25)" },
|
|
242
|
+
name: { type: "string", description: "Filter by exact template name" },
|
|
243
|
+
status: { type: "string", description: "Filter by status (APPROVED, PENDING, REJECTED, PAUSED, DISABLED)" },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "create_template",
|
|
249
|
+
description: "Submit a new template for Meta review. Templates become usable only after approval (usually minutes to hours).",
|
|
250
|
+
inputSchema: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
name: { type: "string", description: "Template name (lowercase, underscores, unique on WABA)" },
|
|
254
|
+
language: { type: "string", description: "BCP-47 language tag (e.g. en_US, pt_BR)" },
|
|
255
|
+
category: { type: "string", enum: ["AUTHENTICATION", "MARKETING", "UTILITY"], description: "Template category" },
|
|
256
|
+
components: {
|
|
257
|
+
type: "array",
|
|
258
|
+
description: "Array of components: HEADER, BODY, FOOTER, BUTTONS. See Cloud API template docs.",
|
|
259
|
+
items: { type: "object" },
|
|
260
|
+
},
|
|
261
|
+
allow_category_change: { type: "boolean", description: "Let Meta re-categorize if needed (default true recommended)" },
|
|
262
|
+
},
|
|
263
|
+
required: ["name", "language", "category", "components"],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
270
|
+
const { name, arguments: args } = request.params;
|
|
271
|
+
const a = (args ?? {}) as Record<string, unknown>;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
switch (name) {
|
|
275
|
+
case "send_text_message": {
|
|
276
|
+
const body = {
|
|
277
|
+
messaging_product: "whatsapp",
|
|
278
|
+
recipient_type: "individual",
|
|
279
|
+
to: a.to,
|
|
280
|
+
type: "text",
|
|
281
|
+
text: {
|
|
282
|
+
body: a.body,
|
|
283
|
+
preview_url: a.preview_url ?? false,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
287
|
+
}
|
|
288
|
+
case "send_template_message": {
|
|
289
|
+
const template: Record<string, unknown> = {
|
|
290
|
+
name: a.template_name,
|
|
291
|
+
language: { code: a.language_code },
|
|
292
|
+
};
|
|
293
|
+
if (a.components) template.components = a.components;
|
|
294
|
+
const body = {
|
|
295
|
+
messaging_product: "whatsapp",
|
|
296
|
+
to: a.to,
|
|
297
|
+
type: "template",
|
|
298
|
+
template,
|
|
299
|
+
};
|
|
300
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
301
|
+
}
|
|
302
|
+
case "send_media_message": {
|
|
303
|
+
const mediaType = String(a.media_type);
|
|
304
|
+
const mediaObj: Record<string, unknown> = {};
|
|
305
|
+
if (a.id) mediaObj.id = a.id;
|
|
306
|
+
if (a.link) mediaObj.link = a.link;
|
|
307
|
+
if (a.caption && mediaType !== "audio") mediaObj.caption = a.caption;
|
|
308
|
+
if (a.filename && mediaType === "document") mediaObj.filename = a.filename;
|
|
309
|
+
const body = {
|
|
310
|
+
messaging_product: "whatsapp",
|
|
311
|
+
recipient_type: "individual",
|
|
312
|
+
to: a.to,
|
|
313
|
+
type: mediaType,
|
|
314
|
+
[mediaType]: mediaObj,
|
|
315
|
+
};
|
|
316
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
317
|
+
}
|
|
318
|
+
case "send_interactive_message": {
|
|
319
|
+
const body = {
|
|
320
|
+
messaging_product: "whatsapp",
|
|
321
|
+
recipient_type: "individual",
|
|
322
|
+
to: a.to,
|
|
323
|
+
type: "interactive",
|
|
324
|
+
interactive: a.interactive,
|
|
325
|
+
};
|
|
326
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
327
|
+
}
|
|
328
|
+
case "send_location_message": {
|
|
329
|
+
const location: Record<string, unknown> = {
|
|
330
|
+
latitude: a.latitude,
|
|
331
|
+
longitude: a.longitude,
|
|
332
|
+
};
|
|
333
|
+
if (a.name) location.name = a.name;
|
|
334
|
+
if (a.address) location.address = a.address;
|
|
335
|
+
const body = {
|
|
336
|
+
messaging_product: "whatsapp",
|
|
337
|
+
recipient_type: "individual",
|
|
338
|
+
to: a.to,
|
|
339
|
+
type: "location",
|
|
340
|
+
location,
|
|
341
|
+
};
|
|
342
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
343
|
+
}
|
|
344
|
+
case "mark_message_as_read": {
|
|
345
|
+
const body = {
|
|
346
|
+
messaging_product: "whatsapp",
|
|
347
|
+
status: "read",
|
|
348
|
+
message_id: a.message_id,
|
|
349
|
+
};
|
|
350
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/messages`, body), null, 2) }] };
|
|
351
|
+
}
|
|
352
|
+
case "upload_media": {
|
|
353
|
+
const body = {
|
|
354
|
+
messaging_product: "whatsapp",
|
|
355
|
+
type: a.mime_type,
|
|
356
|
+
file: {
|
|
357
|
+
data: a.file_base64,
|
|
358
|
+
filename: a.filename,
|
|
359
|
+
contentType: a.mime_type,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${PHONE_NUMBER_ID}/media`, body, { multipart: true }), null, 2) }] };
|
|
363
|
+
}
|
|
364
|
+
case "retrieve_media_url":
|
|
365
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("GET", `/${a.media_id}`), null, 2) }] };
|
|
366
|
+
case "delete_media":
|
|
367
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("DELETE", `/${a.media_id}`), null, 2) }] };
|
|
368
|
+
case "list_templates": {
|
|
369
|
+
const q = new URLSearchParams();
|
|
370
|
+
if (a.limit !== undefined) q.set("limit", String(a.limit));
|
|
371
|
+
if (a.name) q.set("name", String(a.name));
|
|
372
|
+
if (a.status) q.set("status", String(a.status));
|
|
373
|
+
const qs = q.toString();
|
|
374
|
+
const path = `/${BUSINESS_ACCOUNT_ID}/message_templates${qs ? `?${qs}` : ""}`;
|
|
375
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("GET", path), null, 2) }] };
|
|
376
|
+
}
|
|
377
|
+
case "create_template": {
|
|
378
|
+
const body: Record<string, unknown> = {
|
|
379
|
+
name: a.name,
|
|
380
|
+
language: a.language,
|
|
381
|
+
category: a.category,
|
|
382
|
+
components: a.components,
|
|
383
|
+
};
|
|
384
|
+
if (a.allow_category_change !== undefined) body.allow_category_change = a.allow_category_change;
|
|
385
|
+
return { content: [{ type: "text", text: JSON.stringify(await whatsappRequest("POST", `/${BUSINESS_ACCOUNT_ID}/message_templates`, body), null, 2) }] };
|
|
386
|
+
}
|
|
387
|
+
default:
|
|
388
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
async function main() {
|
|
396
|
+
if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
|
|
397
|
+
const { default: express } = await import("express");
|
|
398
|
+
const { randomUUID } = await import("node:crypto");
|
|
399
|
+
const app = express();
|
|
400
|
+
app.use(express.json());
|
|
401
|
+
const transports = new Map<string, StreamableHTTPServerTransport>();
|
|
402
|
+
app.get("/health", (_req: unknown, res: { json: (body: unknown) => unknown }) => res.json({ status: "ok", sessions: transports.size }));
|
|
403
|
+
app.post("/mcp", async (req: { headers: Record<string, string | string[] | undefined>; body: unknown }, res: { status: (code: number) => { json: (body: unknown) => unknown } }) => {
|
|
404
|
+
const sid = req.headers["mcp-session-id"] as string | undefined;
|
|
405
|
+
if (sid && transports.has(sid)) { await transports.get(sid)!.handleRequest(req as never, res as never, req.body); return; }
|
|
406
|
+
if (!sid && isInitializeRequest(req.body)) {
|
|
407
|
+
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
|
|
408
|
+
t.onclose = () => { if (t.sessionId) transports.delete(t.sessionId); };
|
|
409
|
+
const s = new Server({ name: "mcp-whatsapp-cloud", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
410
|
+
(server as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.forEach((v, k) => (s as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.set(k, v));
|
|
411
|
+
(server as unknown as { _notificationHandlers?: Map<unknown, unknown> })._notificationHandlers?.forEach((v, k) => (s as unknown as { _notificationHandlers: Map<unknown, unknown> })._notificationHandlers.set(k, v));
|
|
412
|
+
await s.connect(t);
|
|
413
|
+
await t.handleRequest(req as never, res as never, req.body); return;
|
|
414
|
+
}
|
|
415
|
+
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
|
|
416
|
+
});
|
|
417
|
+
app.get("/mcp", async (req: { headers: Record<string, string | string[] | undefined> }, res: { status: (code: number) => { send: (body: string) => unknown } }) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req as never, res as never); else res.status(400).send("Invalid session"); });
|
|
418
|
+
app.delete("/mcp", async (req: { headers: Record<string, string | string[] | undefined> }, res: { status: (code: number) => { send: (body: string) => unknown } }) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req as never, res as never); else res.status(400).send("Invalid session"); });
|
|
419
|
+
const port = Number(process.env.MCP_PORT) || 3000;
|
|
420
|
+
app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
|
|
421
|
+
} else {
|
|
422
|
+
const transport = new StdioServerTransport();
|
|
423
|
+
await server.connect(transport);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
main().catch(console.error);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|