@iflow-mcp/mmarqueti-whatsapp-mcp 1.0.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/Dockerfile ADDED
@@ -0,0 +1,15 @@
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ RUN npm install -g pnpm
6
+
7
+ COPY package.json pnpm-lock.yaml ./
8
+ RUN pnpm install
9
+
10
+ COPY . .
11
+ RUN pnpm run build
12
+
13
+ EXPOSE 4000
14
+
15
+ CMD ["pnpm", "start"]
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # WhatsApp Cloud API MCP Server – Spec v1.0
2
+
3
+ ## Overview
4
+ This project defines version **1.0** of an **MCP Server** that exposes the **WhatsApp Cloud API** to LLM agents through standardized MCP tools.
5
+
6
+ Goal: Provide a minimal, clean, production-ready baseline so developers can use WhatsApp messaging inside AI agents with zero complexity.
7
+
8
+ ---
9
+
10
+ # 1. Objectives
11
+
12
+ - Create a lightweight MCP server (TypeScript + Node.js).
13
+ - Expose WhatsApp Cloud API functionalities as tools.
14
+ - Provide helpers for webhook verification.
15
+ - Be deployable with a single command (Docker + local dev).
16
+
17
+ ---
18
+
19
+ # 2. Tech Stack
20
+
21
+ - **Node.js 20+**
22
+ - **TypeScript**
23
+ - **Express** (or Fastify) for HTTP + webhooks
24
+ - **MCP Server SDK**
25
+ - **Axios** for outbound WhatsApp API requests
26
+ - **WebSocket** for MCP transport (mandatory)
27
+ - **Dotenv** for configs
28
+ - **Pino** or console logging
29
+
30
+ ---
31
+
32
+ # 3. Project Structure (v1.0)
33
+
34
+ ```
35
+ /src
36
+ /mcp
37
+ server.ts
38
+ /whatsapp
39
+ client.ts
40
+ webhook.ts
41
+ /config
42
+ env.ts
43
+ index.ts
44
+ .env.example
45
+ mcp.json
46
+ package.json
47
+ README.md
48
+ ```
49
+
50
+ ---
51
+
52
+ # 4. Environment Variables
53
+
54
+ Required:
55
+
56
+ ```
57
+ META_WHATSAPP_TOKEN=
58
+ META_WHATSAPP_PHONE_ID=
59
+ META_WHATSAPP_WABA_ID=
60
+ META_VERIFY_TOKEN=
61
+ PORT=4000
62
+ MCP_PORT=8000
63
+ ```
64
+
65
+ Optional:
66
+
67
+ ```
68
+ LOG_LEVEL=debug
69
+ ```
70
+
71
+ ---
72
+
73
+ # 5. WhatsApp Cloud API Endpoints to Support (v1.0)
74
+
75
+ The MCP server will call these official endpoints:
76
+
77
+ ### **Send Text Message**
78
+ ```
79
+ POST /v18.0/{PHONE_ID}/messages
80
+ ```
81
+
82
+ ### **Send Template Message**
83
+ ```
84
+ POST /v18.0/{PHONE_ID}/messages
85
+ ```
86
+
87
+ ### **Get Media URL**
88
+ ```
89
+ GET /v18.0/{MEDIA_ID}
90
+ ```
91
+
92
+ ### **Download Media**
93
+ ```
94
+ GET {MEDIA_URL}
95
+ ```
96
+
97
+ ### **Health Check**
98
+ ```
99
+ GET /v18.0/{PHONE_ID}
100
+ ```
101
+
102
+ ---
103
+
104
+ # 6. Webhook Handling
105
+
106
+ Expose:
107
+ ```
108
+ GET /webhook (verification)
109
+ ```
110
+
111
+ Features:
112
+ - Validate `hub.verify_token`
113
+
114
+ ---
115
+
116
+ # 7. MCP Tools (v1.0)
117
+
118
+ Tools exposed to agents:
119
+
120
+ ## **1. send_text_message**
121
+ Send a simple text message to a WhatsApp user.
122
+
123
+ ### Params:
124
+ ```json
125
+ {
126
+ "to": "string",
127
+ "text": "string"
128
+ }
129
+ ```
130
+
131
+ ## **2. send_template_message**
132
+ Send a WhatsApp template message.
133
+
134
+ ### Params:
135
+ ```json
136
+ {
137
+ "to": "string",
138
+ "template_name": "string",
139
+ "language": "string",
140
+ "components": []
141
+ }
142
+ ```
143
+
144
+ ## **3. get_media**
145
+ Retrieve a media file by MEDIA_ID.
146
+
147
+ ### Params:
148
+ ```json
149
+ {
150
+ "media_id": "string"
151
+ }
152
+ ```
153
+
154
+ ## **4. health_check**
155
+ Verify connectivity with WhatsApp Cloud API.
156
+
157
+ ---
158
+
159
+ # 8. Security
160
+
161
+ - Validate Meta webhook signature (optional v1).
162
+ - Restrict MCP tool usage to authenticated WebSocket clients.
163
+ - Sanitize user inputs.
164
+ - Never log tokens.
165
+
166
+ ---
167
+
168
+ # 9. Documentation Requirements
169
+
170
+ ## Setup Instructions
171
+
172
+ 1. **Install Dependencies**
173
+ ```bash
174
+ npm install
175
+ ```
176
+
177
+ 2. **Configure Environment**
178
+ Copy `.env.example` to `.env` and fill in your Meta WhatsApp API credentials.
179
+ ```bash
180
+ cp .env.example .env
181
+ ```
182
+
183
+ 3. **Run Locally**
184
+ ```bash
185
+ npm run dev
186
+ ```
187
+ Server will start on port 4000 (HTTP) and expose MCP on WebSocket.
188
+
189
+ 4. **Configure Webhook**
190
+ - Expose your local server using ngrok or similar: `ngrok http 4000`
191
+ - In Meta App Dashboard, set Webhook URL to `https://<your-ngrok-url>/webhook`
192
+ - Set Verify Token to match `META_VERIFY_TOKEN` in `.env`
193
+
194
+ ## Docker Usage
195
+
196
+ ```bash
197
+ docker build -t whatsapp-mcp .
198
+ docker run -p 4000:4000 --env-file .env whatsapp-mcp
199
+ ```
200
+
201
+ ## Example Usage with Claude Desktop
202
+
203
+ Add to your `claude_desktop_config.json`:
204
+
205
+ ```json
206
+ {
207
+ "mcpServers": {
208
+ "whatsapp": {
209
+ "command": "docker",
210
+ "args": ["run", "-i", "--rm", "--env-file", "/path/to/.env", "whatsapp-mcp"]
211
+ }
212
+ }
213
+ }
214
+ ```
215
+ (Note: For local dev without docker, point to the build output)
216
+
217
+ # 10. Contact
218
+
219
+ For any inquiries or similar projects, please contact: marcelo@marcelomarchetti.com
220
+
@@ -0,0 +1,25 @@
1
+ import dotenv from 'dotenv';
2
+ import { z } from 'zod';
3
+ // Suppress dotenv debug output by not logging to console
4
+ dotenv.config({ quiet: true });
5
+ const envSchema = z.object({
6
+ META_WHATSAPP_TOKEN: z.string().default('test_token'),
7
+ META_WHATSAPP_PHONE_ID: z.string().default('test_phone_id'),
8
+ META_WHATSAPP_WABA_ID: z.string().default('test_waba_id'),
9
+ META_VERIFY_TOKEN: z.string().default('test_verify_token'),
10
+ PORT: z.string().default('4000'),
11
+ MCP_PORT: z.string().default('8000'),
12
+ LOG_LEVEL: z.string().default('info'),
13
+ });
14
+ const parseEnv = () => {
15
+ try {
16
+ return envSchema.parse(process.env);
17
+ }
18
+ catch (error) {
19
+ if (error instanceof z.ZodError) {
20
+ console.error('❌ Invalid environment variables:', error.flatten().fieldErrors);
21
+ }
22
+ process.exit(1);
23
+ }
24
+ };
25
+ export const config = parseEnv();
package/dist/index.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import express from 'express';
3
+ import { createServer } from 'http';
4
+ import { config } from './config/env.js';
5
+ import { webhookRouter } from './whatsapp/webhook.js';
6
+ import { mcpServer } from './mcp/server.js';
7
+ // Start servers
8
+ async function main() {
9
+ try {
10
+ const args = process.argv.slice(2);
11
+ const useStdio = args.includes('--stdio');
12
+ if (useStdio) {
13
+ // Start MCP Server (Stdio) only - no HTTP server needed
14
+ await mcpServer.startStdio();
15
+ }
16
+ else {
17
+ // Setup HTTP server for WebSocket mode
18
+ const app = express();
19
+ const httpServer = createServer(app);
20
+ // Middleware
21
+ app.use(express.json());
22
+ // Routes
23
+ app.use('/webhook', webhookRouter);
24
+ // Health check
25
+ app.get('/health', (req, res) => {
26
+ res.status(200).json({ status: 'ok' });
27
+ });
28
+ // Start MCP Server (WebSocket)
29
+ await mcpServer.start(httpServer);
30
+ // Start HTTP Server
31
+ httpServer.listen(config.PORT, () => {
32
+ console.error(`🚀 Server running on port ${config.PORT}`);
33
+ console.error(`webhook: http://localhost:${config.PORT}/webhook`);
34
+ console.error(`mcp: ws://localhost:${config.PORT}`);
35
+ });
36
+ }
37
+ }
38
+ catch (error) {
39
+ console.error('Failed to start server:', error);
40
+ process.exit(1);
41
+ }
42
+ }
43
+ main();
@@ -0,0 +1,104 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { WebSocketServer } from 'ws';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { whatsAppClient } from '../whatsapp/client.js';
6
+ class WhatsAppMcpServer {
7
+ server;
8
+ wss = null;
9
+ constructor() {
10
+ this.server = new McpServer({
11
+ name: 'WhatsApp MCP Server',
12
+ version: '1.0.0',
13
+ });
14
+ this.registerTools();
15
+ }
16
+ registerTools() {
17
+ this.server.tool('send_text_message', {
18
+ to: z.string().describe('The WhatsApp phone number to send the message to'),
19
+ text: z.string().describe('The text content of the message'),
20
+ }, async ({ to, text }) => {
21
+ const result = await whatsAppClient.sendText(to, text);
22
+ return {
23
+ content: [{ type: 'text', text: JSON.stringify(result) }],
24
+ };
25
+ });
26
+ this.server.tool('send_template_message', {
27
+ to: z.string().describe('The WhatsApp phone number'),
28
+ template_name: z.string().describe('Name of the template'),
29
+ language: z.string().describe('Language code (e.g., en_US)'),
30
+ components: z.array(z.any()).optional().describe('Template components'),
31
+ }, async ({ to, template_name, language, components }) => {
32
+ const result = await whatsAppClient.sendTemplate(to, template_name, language, components);
33
+ return {
34
+ content: [{ type: 'text', text: JSON.stringify(result) }],
35
+ };
36
+ });
37
+ this.server.tool('get_media', {
38
+ media_id: z.string().describe('The ID of the media to retrieve'),
39
+ }, async ({ media_id }) => {
40
+ const url = await whatsAppClient.getMediaUrl(media_id);
41
+ return {
42
+ content: [{ type: 'text', text: url }],
43
+ };
44
+ });
45
+ this.server.tool('health_check', {}, async () => {
46
+ const isHealthy = await whatsAppClient.healthCheck();
47
+ return {
48
+ content: [{ type: 'text', text: isHealthy ? 'healthy' : 'unhealthy' }],
49
+ isError: !isHealthy,
50
+ };
51
+ });
52
+ }
53
+ async start(httpServer) {
54
+ this.wss = new WebSocketServer({ server: httpServer });
55
+ this.wss.on('connection', async (ws) => {
56
+ console.error('New MCP WebSocket connection');
57
+ // Minimal Transport implementation for WebSocket
58
+ const transport = {
59
+ start: async () => { },
60
+ send: async (message) => {
61
+ if (ws.readyState === ws.OPEN) {
62
+ ws.send(JSON.stringify(message));
63
+ }
64
+ },
65
+ close: async () => {
66
+ ws.close();
67
+ },
68
+ onclose: undefined,
69
+ onerror: undefined,
70
+ onmessage: undefined,
71
+ };
72
+ ws.on('message', (data) => {
73
+ try {
74
+ const message = JSON.parse(data.toString());
75
+ if (transport.onmessage) {
76
+ transport.onmessage(message);
77
+ }
78
+ }
79
+ catch (error) {
80
+ console.error('Failed to parse message:', error);
81
+ }
82
+ });
83
+ ws.on('close', () => {
84
+ if (transport.onclose) {
85
+ transport.onclose();
86
+ }
87
+ });
88
+ ws.on('error', (error) => {
89
+ console.error('WebSocket error:', error);
90
+ if (transport.onerror) {
91
+ transport.onerror(error);
92
+ }
93
+ });
94
+ // @ts-ignore - McpServer.connect expects a Transport interface which matches our object structure
95
+ await this.server.connect(transport);
96
+ });
97
+ }
98
+ async startStdio() {
99
+ const transport = new StdioServerTransport();
100
+ await this.server.connect(transport);
101
+ console.error('MCP Server running on stdio');
102
+ }
103
+ }
104
+ export const mcpServer = new WhatsAppMcpServer();
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import request from 'supertest';
3
+ import express from 'express';
4
+ import { webhookRouter } from '../webhook.js';
5
+ // Mock config
6
+ vi.mock('../../config/env.js', () => ({
7
+ config: {
8
+ META_VERIFY_TOKEN: 'test-token',
9
+ META_WHATSAPP_TOKEN: 'test-token',
10
+ META_WHATSAPP_PHONE_ID: 'test-phone-id',
11
+ PORT: 4000
12
+ }
13
+ }));
14
+ const app = express();
15
+ app.use(express.json());
16
+ app.use('/webhook', webhookRouter);
17
+ describe('Webhook Integration', () => {
18
+ beforeEach(() => {
19
+ // Clear storage before each test
20
+ // We need to access the private array or just add a clear method to storage
21
+ // For now, we can just assume it works or add a clear method if needed.
22
+ // But since we are testing ingestion, we can just check if count increases.
23
+ });
24
+ describe('GET /webhook', () => {
25
+ it('should verify token correctly', async () => {
26
+ const response = await request(app)
27
+ .get('/webhook')
28
+ .query({
29
+ 'hub.mode': 'subscribe',
30
+ 'hub.verify_token': 'test-token',
31
+ 'hub.challenge': '12345'
32
+ });
33
+ expect(response.status).toBe(200);
34
+ expect(response.text).toBe('12345');
35
+ });
36
+ it('should reject invalid token', async () => {
37
+ const response = await request(app)
38
+ .get('/webhook')
39
+ .query({
40
+ 'hub.mode': 'subscribe',
41
+ 'hub.verify_token': 'wrong-token',
42
+ 'hub.challenge': '12345'
43
+ });
44
+ expect(response.status).toBe(403);
45
+ });
46
+ it('should reject missing params', async () => {
47
+ const response = await request(app)
48
+ .get('/webhook');
49
+ expect(response.status).toBe(400);
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,111 @@
1
+ import axios from 'axios';
2
+ import { config } from '../config/env.js';
3
+ // Mock mode for testing without real API credentials
4
+ const isMockMode = !process.env.META_WHATSAPP_TOKEN ||
5
+ process.env.META_WHATSAPP_TOKEN === 'test_token' ||
6
+ !process.env.META_WHATSAPP_PHONE_ID;
7
+ export class WhatsAppClient {
8
+ client;
9
+ constructor() {
10
+ if (isMockMode) {
11
+ // Create mock client that doesn't make real API calls
12
+ this.client = {
13
+ get: async (url, config) => {
14
+ console.error(`[MOCK] GET request to ${url}`);
15
+ if (url.includes('PHONE_ID')) {
16
+ return { data: { id: 'mock_phone_id' } };
17
+ }
18
+ if (url.match(/^[0-9]+$/)) {
19
+ return { data: { url: 'https://mock.media.url/file.pdf' } };
20
+ }
21
+ return { data: {} };
22
+ },
23
+ post: async (url, data, config) => {
24
+ console.error(`[MOCK] POST request to ${url}`, data);
25
+ return { data: { success: true, message: 'Mock response' } };
26
+ }
27
+ };
28
+ }
29
+ else {
30
+ this.client = axios.create({
31
+ baseURL: 'https://graph.facebook.com/v18.0',
32
+ headers: {
33
+ 'Authorization': `Bearer ${config.META_WHATSAPP_TOKEN}`,
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ });
37
+ }
38
+ }
39
+ async sendText(to, text) {
40
+ try {
41
+ const response = await this.client.post(`/${config.META_WHATSAPP_PHONE_ID}/messages`, {
42
+ messaging_product: 'whatsapp',
43
+ recipient_type: 'individual',
44
+ to,
45
+ type: 'text',
46
+ text: { body: text },
47
+ });
48
+ return response.data;
49
+ }
50
+ catch (error) {
51
+ console.error('Error sending text message:', error.response?.data || error.message);
52
+ throw error;
53
+ }
54
+ }
55
+ async sendTemplate(to, templateName, language, components = []) {
56
+ try {
57
+ const response = await this.client.post(`/${config.META_WHATSAPP_PHONE_ID}/messages`, {
58
+ messaging_product: 'whatsapp',
59
+ recipient_type: 'individual',
60
+ to,
61
+ type: 'template',
62
+ template: {
63
+ name: templateName,
64
+ language: { code: language },
65
+ components,
66
+ },
67
+ });
68
+ return response.data;
69
+ }
70
+ catch (error) {
71
+ console.error('Error sending template message:', error.response?.data || error.message);
72
+ throw error;
73
+ }
74
+ }
75
+ async getMediaUrl(mediaId) {
76
+ try {
77
+ const response = await this.client.get(`/${mediaId}`);
78
+ return response.data.url;
79
+ }
80
+ catch (error) {
81
+ console.error('Error getting media URL:', error.response?.data || error.message);
82
+ throw error;
83
+ }
84
+ }
85
+ async downloadMedia(url) {
86
+ try {
87
+ const response = await this.client.get(url, {
88
+ responseType: 'arraybuffer',
89
+ headers: {
90
+ 'Authorization': `Bearer ${config.META_WHATSAPP_TOKEN}`,
91
+ },
92
+ });
93
+ return response.data;
94
+ }
95
+ catch (error) {
96
+ console.error('Error downloading media:', error.response?.data || error.message);
97
+ throw error;
98
+ }
99
+ }
100
+ async healthCheck() {
101
+ try {
102
+ await this.client.get(`/${config.META_WHATSAPP_PHONE_ID}`);
103
+ return true;
104
+ }
105
+ catch (error) {
106
+ console.error('Health check failed:', error.response?.data || error.message);
107
+ return false;
108
+ }
109
+ }
110
+ }
111
+ export const whatsAppClient = new WhatsAppClient();
@@ -0,0 +1,47 @@
1
+ export function normalizeMessage(webhookPayload) {
2
+ try {
3
+ const entry = webhookPayload.entry?.[0];
4
+ const change = entry?.changes?.[0];
5
+ const value = change?.value;
6
+ const message = value?.messages?.[0];
7
+ if (!message) {
8
+ return null;
9
+ }
10
+ const normalized = {
11
+ from: message.from,
12
+ id: message.id,
13
+ timestamp: parseInt(message.timestamp, 10),
14
+ type: 'unknown',
15
+ raw: message,
16
+ };
17
+ switch (message.type) {
18
+ case 'text':
19
+ normalized.type = 'text';
20
+ normalized.text = message.text.body;
21
+ break;
22
+ case 'image':
23
+ normalized.type = 'image';
24
+ normalized.image = message.image;
25
+ break;
26
+ case 'audio':
27
+ normalized.type = 'audio';
28
+ normalized.audio = message.audio;
29
+ break;
30
+ case 'document':
31
+ normalized.type = 'document';
32
+ normalized.document = message.document;
33
+ break;
34
+ case 'interactive':
35
+ normalized.type = 'interactive';
36
+ normalized.interactive = message.interactive;
37
+ break;
38
+ default:
39
+ normalized.type = 'unknown';
40
+ }
41
+ return normalized;
42
+ }
43
+ catch (error) {
44
+ console.error('Error normalizing message:', error);
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,17 @@
1
+ export class MessageStorage {
2
+ messages = [];
3
+ limit;
4
+ constructor(limit = 50) {
5
+ this.limit = limit;
6
+ }
7
+ addMessage(message) {
8
+ this.messages.unshift(message);
9
+ if (this.messages.length > this.limit) {
10
+ this.messages.pop();
11
+ }
12
+ }
13
+ getRecentMessages(limit = 20) {
14
+ return this.messages.slice(0, limit);
15
+ }
16
+ }
17
+ export const messageStorage = new MessageStorage();
@@ -0,0 +1,22 @@
1
+ import express from 'express';
2
+ import { config } from '../config/env.js';
3
+ const router = express.Router();
4
+ // Verification endpoint
5
+ router.get('/', (req, res) => {
6
+ const mode = req.query['hub.mode'];
7
+ const token = req.query['hub.verify_token'];
8
+ const challenge = req.query['hub.challenge'];
9
+ if (mode && token) {
10
+ if (mode === 'subscribe' && token === config.META_VERIFY_TOKEN) {
11
+ console.log('WEBHOOK_VERIFIED');
12
+ res.status(200).send(challenge);
13
+ }
14
+ else {
15
+ res.sendStatus(403);
16
+ }
17
+ }
18
+ else {
19
+ res.sendStatus(400);
20
+ }
21
+ });
22
+ export const webhookRouter = router;