@codespar/mcp-zenvia 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,114 @@
1
+ # @codespar/mcp-zenvia
2
+
3
+ > MCP server for **Zenvia** — multichannel messaging (SMS, WhatsApp, RCS)
4
+
5
+ [![npm](https://img.shields.io/npm/v/@codespar/mcp-zenvia)](https://www.npmjs.com/package/@codespar/mcp-zenvia)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Quick Start
9
+
10
+ ### Claude Desktop
11
+
12
+ Add to `~/.config/claude/claude_desktop_config.json`:
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "zenvia": {
18
+ "command": "npx",
19
+ "args": ["-y", "@codespar/mcp-zenvia"],
20
+ "env": {
21
+ "ZENVIA_API_TOKEN": "your-token"
22
+ }
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ ### Claude Code
29
+
30
+ ```bash
31
+ claude mcp add zenvia -- npx @codespar/mcp-zenvia
32
+ ```
33
+
34
+ ### Cursor / VS Code
35
+
36
+ Add to `.cursor/mcp.json` or `.vscode/mcp.json`:
37
+
38
+ ```json
39
+ {
40
+ "servers": {
41
+ "zenvia": {
42
+ "command": "npx",
43
+ "args": ["-y", "@codespar/mcp-zenvia"],
44
+ "env": {
45
+ "ZENVIA_API_TOKEN": "your-token"
46
+ }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Tools
53
+
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `send_sms` | Send an SMS message |
57
+ | `send_whatsapp` | Send a WhatsApp message |
58
+ | `send_rcs` | Send an RCS (Rich Communication Services) message |
59
+ | `get_message_status` | Get message delivery status by ID |
60
+ | `list_channels` | List available messaging channels |
61
+ | `create_subscription` | Create a webhook subscription for message events |
62
+ | `list_contacts` | List contacts from the contact base |
63
+ | `send_template` | Send a WhatsApp template message (pre-approved) |
64
+
65
+ ## Authentication
66
+
67
+ Zenvia uses an API token passed via the `X-API-TOKEN` header.
68
+
69
+ ## Sandbox / Testing
70
+
71
+ Zenvia provides a sandbox via the dashboard for testing messages.
72
+
73
+ ### Get your credentials
74
+
75
+ 1. Go to [Zenvia](https://app.zenvia.com)
76
+ 2. Create an account
77
+ 3. Get your API token from the dashboard
78
+ 4. Set the `ZENVIA_API_TOKEN` environment variable
79
+
80
+ ## Environment Variables
81
+
82
+ | Variable | Required | Description |
83
+ |----------|----------|-------------|
84
+ | `ZENVIA_API_TOKEN` | Yes | API token from Zenvia dashboard |
85
+
86
+ ## Roadmap
87
+
88
+ ### v0.2 (planned)
89
+ - `create_contact_list` — Create a contact list for campaigns
90
+ - `send_batch` — Send batch messages to a contact list
91
+ - `get_report` — Get message delivery reports
92
+ - `create_flow` — Create a conversational flow
93
+ - `list_templates` — List approved message templates
94
+
95
+ ### v0.3 (planned)
96
+ - `chatbot_integration` — Integrate with Zenvia chatbot builder
97
+ - `analytics_dashboard` — Get channel analytics and metrics
98
+
99
+ Want to contribute? [Open a PR](https://github.com/codespar/mcp-dev-brasil) or [request a tool](https://github.com/codespar/mcp-dev-brasil/issues).
100
+
101
+ ## Links
102
+
103
+ - [Zenvia Website](https://zenvia.com)
104
+ - [Zenvia API Documentation](https://zenvia.github.io/zenvia-openapi-spec)
105
+ - [MCP Dev Brasil](https://github.com/codespar/mcp-dev-brasil)
106
+ - [Landing Page](https://codespar.dev/mcp)
107
+
108
+ ## Enterprise
109
+
110
+ Need governance, budget limits, and audit trails for agent payments? [CodeSpar Enterprise](https://codespar.dev/enterprise) adds policy engine, payment routing, and compliance templates on top of these MCP servers.
111
+
112
+ ## License
113
+
114
+ MIT
package/dist/index.js CHANGED
@@ -1,22 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * MCP Server for Zenvia — multi-channel messaging (SMS, WhatsApp, RCS).
3
+ * MCP Server for Zenvia — multi-channel messaging (SMS, WhatsApp, RCS, Email, Voice, Facebook).
4
4
  *
5
5
  * Tools:
6
6
  * - send_sms: Send an SMS message
7
7
  * - send_whatsapp: Send a WhatsApp message
8
8
  * - send_rcs: Send an RCS message
9
+ * - send_email: Send a transactional email
10
+ * - send_voice: Send a voice message (TTS or pre-recorded audio)
11
+ * - send_facebook_message: Send a Facebook Messenger message
9
12
  * - get_message_status: Get message delivery status
10
13
  * - list_channels: List available messaging channels
11
14
  * - create_subscription: Create a webhook subscription for events
15
+ * - list_subscriptions: List all webhook subscriptions
16
+ * - delete_subscription: Delete a webhook subscription
12
17
  * - list_contacts: List contacts
18
+ * - create_contact: Create a contact in the contact base
19
+ * - delete_contact: Delete a contact
13
20
  * - send_template: Send a WhatsApp template message
21
+ * - list_templates: List approved WhatsApp templates
22
+ * - get_report_entries: Get message report entries by date range
23
+ * - add_opt_out: Add a phone number to the opt-out list
14
24
  *
15
25
  * Environment:
16
26
  * ZENVIA_API_TOKEN — API token from https://app.zenvia.com/
17
27
  */
18
28
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
29
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
31
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
20
32
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
21
33
  const API_TOKEN = process.env.ZENVIA_API_TOKEN || "";
22
34
  const BASE_URL = "https://api.zenvia.com/v2";
@@ -33,9 +45,11 @@ async function zenviaRequest(method, path, body) {
33
45
  const err = await res.text();
34
46
  throw new Error(`Zenvia API ${res.status}: ${err}`);
35
47
  }
36
- return res.json();
48
+ // Some endpoints (DELETE) return empty
49
+ const text = await res.text();
50
+ return text ? JSON.parse(text) : { ok: true };
37
51
  }
38
- const server = new Server({ name: "mcp-zenvia", version: "0.1.0" }, { capabilities: { tools: {} } });
52
+ const server = new Server({ name: "mcp-zenvia", version: "0.2.0" }, { capabilities: { tools: {} } });
39
53
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
40
54
  tools: [
41
55
  {
@@ -77,6 +91,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
77
91
  required: ["from", "to", "text"],
78
92
  },
79
93
  },
94
+ {
95
+ name: "send_email",
96
+ description: "Send a transactional email",
97
+ inputSchema: {
98
+ type: "object",
99
+ properties: {
100
+ from: { type: "string", description: "Sender email address (verified domain)" },
101
+ to: { type: "string", description: "Recipient email address" },
102
+ subject: { type: "string", description: "Email subject" },
103
+ html: { type: "string", description: "HTML body of the email" },
104
+ text: { type: "string", description: "Plain text body (fallback)" },
105
+ },
106
+ required: ["from", "to", "subject"],
107
+ },
108
+ },
109
+ {
110
+ name: "send_voice",
111
+ description: "Send a voice message via TTS or pre-recorded audio URL",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ from: { type: "string", description: "Sender ID (Voice channel)" },
116
+ to: { type: "string", description: "Recipient phone number with country code" },
117
+ text: { type: "string", description: "Text to be spoken (TTS) — use either text or audioUrl" },
118
+ audioUrl: { type: "string", description: "URL of pre-recorded audio file — use either text or audioUrl" },
119
+ },
120
+ required: ["from", "to"],
121
+ },
122
+ },
123
+ {
124
+ name: "send_facebook_message",
125
+ description: "Send a Facebook Messenger message",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ from: { type: "string", description: "Sender ID (Facebook page)" },
130
+ to: { type: "string", description: "Recipient PSID (page-scoped user ID)" },
131
+ text: { type: "string", description: "Message text" },
132
+ },
133
+ required: ["from", "to", "text"],
134
+ },
135
+ },
80
136
  {
81
137
  name: "get_message_status",
82
138
  description: "Get message delivery status by ID",
@@ -100,12 +156,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
100
156
  type: "object",
101
157
  properties: {
102
158
  url: { type: "string", description: "Webhook URL to receive events" },
103
- channel: { type: "string", enum: ["sms", "whatsapp", "rcs"], description: "Channel to subscribe to" },
159
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs", "email", "voice", "facebook"], description: "Channel to subscribe to" },
104
160
  eventType: { type: "string", enum: ["MESSAGE", "MESSAGE_STATUS"], description: "Event type" },
105
161
  },
106
162
  required: ["url", "channel", "eventType"],
107
163
  },
108
164
  },
165
+ {
166
+ name: "list_subscriptions",
167
+ description: "List all webhook subscriptions",
168
+ inputSchema: { type: "object", properties: {} },
169
+ },
170
+ {
171
+ name: "delete_subscription",
172
+ description: "Delete a webhook subscription by ID",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ id: { type: "string", description: "Subscription ID" },
177
+ },
178
+ required: ["id"],
179
+ },
180
+ },
109
181
  {
110
182
  name: "list_contacts",
111
183
  description: "List contacts from the contact base",
@@ -117,6 +189,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
117
189
  },
118
190
  },
119
191
  },
192
+ {
193
+ name: "create_contact",
194
+ description: "Create a contact in the contact base",
195
+ inputSchema: {
196
+ type: "object",
197
+ properties: {
198
+ name: { type: "string", description: "Contact full name" },
199
+ phone: { type: "string", description: "Phone number with country code" },
200
+ email: { type: "string", description: "Email address" },
201
+ groupId: { type: "string", description: "Optional group ID to add the contact to" },
202
+ },
203
+ required: ["name"],
204
+ },
205
+ },
206
+ {
207
+ name: "delete_contact",
208
+ description: "Delete a contact by ID",
209
+ inputSchema: {
210
+ type: "object",
211
+ properties: {
212
+ id: { type: "string", description: "Contact ID" },
213
+ },
214
+ required: ["id"],
215
+ },
216
+ },
120
217
  {
121
218
  name: "send_template",
122
219
  description: "Send a WhatsApp template message (pre-approved)",
@@ -134,6 +231,42 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
134
231
  required: ["from", "to", "templateId"],
135
232
  },
136
233
  },
234
+ {
235
+ name: "list_templates",
236
+ description: "List approved message templates (WhatsApp/SMS/RCS)",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs"], description: "Filter templates by channel" },
241
+ },
242
+ },
243
+ },
244
+ {
245
+ name: "get_report_entries",
246
+ description: "Get message report entries within a date range",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs", "email", "voice", "facebook"], description: "Channel to report on" },
251
+ startDate: { type: "string", description: "ISO 8601 start date (e.g. 2026-04-01)" },
252
+ endDate: { type: "string", description: "ISO 8601 end date (e.g. 2026-04-24)" },
253
+ },
254
+ required: ["channel", "startDate", "endDate"],
255
+ },
256
+ },
257
+ {
258
+ name: "add_opt_out",
259
+ description: "Add a phone number to the opt-out list (suppresses future messages)",
260
+ inputSchema: {
261
+ type: "object",
262
+ properties: {
263
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs", "voice"], description: "Channel for the opt-out" },
264
+ from: { type: "string", description: "Sender ID the opt-out applies to" },
265
+ phone: { type: "string", description: "Phone number to opt out (with country code)" },
266
+ },
267
+ required: ["channel", "from", "phone"],
268
+ },
269
+ },
137
270
  ],
138
271
  }));
139
272
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -146,12 +279,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
146
279
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/whatsapp/messages", { from: args?.from, to: args?.to, contents: [{ type: "text", text: args?.text }] }), null, 2) }] };
147
280
  case "send_rcs":
148
281
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/rcs/messages", { from: args?.from, to: args?.to, contents: [{ type: "text", text: args?.text }] }), null, 2) }] };
282
+ case "send_email": {
283
+ const contents = [];
284
+ if (args?.html)
285
+ contents.push({ type: "email", html: args.html, subject: args?.subject });
286
+ else if (args?.text)
287
+ contents.push({ type: "email", text: args.text, subject: args?.subject });
288
+ else
289
+ contents.push({ type: "email", subject: args?.subject });
290
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/email/messages", { from: args?.from, to: args?.to, contents }), null, 2) }] };
291
+ }
292
+ case "send_voice": {
293
+ const contents = [];
294
+ if (args?.audioUrl)
295
+ contents.push({ type: "audio", url: args.audioUrl });
296
+ else if (args?.text)
297
+ contents.push({ type: "text", text: args.text });
298
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/voice/messages", { from: args?.from, to: args?.to, contents }), null, 2) }] };
299
+ }
300
+ case "send_facebook_message":
301
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/facebook/messages", { from: args?.from, to: args?.to, contents: [{ type: "text", text: args?.text }] }), null, 2) }] };
149
302
  case "get_message_status":
150
303
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/reports/${args?.id}`), null, 2) }] };
151
304
  case "list_channels":
152
305
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", "/channels"), null, 2) }] };
153
306
  case "create_subscription":
154
307
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/subscriptions", { webhook: { url: args?.url }, criteria: { channel: args?.channel }, eventType: args?.eventType }), null, 2) }] };
308
+ case "list_subscriptions":
309
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", "/subscriptions"), null, 2) }] };
310
+ case "delete_subscription":
311
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("DELETE", `/subscriptions/${args?.id}`), null, 2) }] };
155
312
  case "list_contacts": {
156
313
  const params = new URLSearchParams();
157
314
  if (args?.page)
@@ -160,8 +317,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
160
317
  params.set("size", String(args.size));
161
318
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/contacts?${params}`), null, 2) }] };
162
319
  }
320
+ case "create_contact": {
321
+ const body = { name: args?.name };
322
+ if (args?.phone)
323
+ body.phone = args.phone;
324
+ if (args?.email)
325
+ body.email = args.email;
326
+ if (args?.groupId)
327
+ body.groupId = args.groupId;
328
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/contacts", body), null, 2) }] };
329
+ }
330
+ case "delete_contact":
331
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("DELETE", `/contacts/${args?.id}`), null, 2) }] };
163
332
  case "send_template":
164
333
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/whatsapp/messages", { from: args?.from, to: args?.to, contents: [{ type: "template", templateId: args?.templateId, fields: args?.fields || {} }] }), null, 2) }] };
334
+ case "list_templates": {
335
+ const params = new URLSearchParams();
336
+ if (args?.channel)
337
+ params.set("channel", String(args.channel));
338
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/templates?${params}`), null, 2) }] };
339
+ }
340
+ case "get_report_entries": {
341
+ const params = new URLSearchParams();
342
+ params.set("startDate", String(args?.startDate));
343
+ params.set("endDate", String(args?.endDate));
344
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/reports/${args?.channel}/entries?${params}`), null, 2) }] };
345
+ }
346
+ case "add_opt_out":
347
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", `/channels/${args?.channel}/senders/${args?.from}/opt-outs`, { phone: args?.phone }), null, 2) }] };
165
348
  default:
166
349
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
167
350
  }
@@ -171,11 +354,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
171
354
  }
172
355
  });
173
356
  async function main() {
174
- if (!API_TOKEN) {
175
- console.error("ZENVIA_API_TOKEN environment variable is required");
176
- process.exit(1);
357
+ if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
358
+ const { default: express } = await import("express");
359
+ const { randomUUID } = await import("node:crypto");
360
+ const app = express();
361
+ app.use(express.json());
362
+ const transports = new Map();
363
+ app.get("/health", (_req, res) => res.json({ status: "ok", sessions: transports.size }));
364
+ app.post("/mcp", async (req, res) => {
365
+ const sid = req.headers["mcp-session-id"];
366
+ if (sid && transports.has(sid)) {
367
+ await transports.get(sid).handleRequest(req, res, req.body);
368
+ return;
369
+ }
370
+ if (!sid && isInitializeRequest(req.body)) {
371
+ const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
372
+ t.onclose = () => { if (t.sessionId)
373
+ transports.delete(t.sessionId); };
374
+ const s = new Server({ name: "mcp-zenvia", version: "0.2.0" }, { capabilities: { tools: {} } });
375
+ server._requestHandlers.forEach((v, k) => s._requestHandlers.set(k, v));
376
+ server._notificationHandlers?.forEach((v, k) => s._notificationHandlers.set(k, v));
377
+ await s.connect(t);
378
+ await t.handleRequest(req, res, req.body);
379
+ return;
380
+ }
381
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
382
+ });
383
+ app.get("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
384
+ await transports.get(sid).handleRequest(req, res);
385
+ else
386
+ res.status(400).send("Invalid session"); });
387
+ app.delete("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
388
+ await transports.get(sid).handleRequest(req, res);
389
+ else
390
+ res.status(400).send("Invalid session"); });
391
+ const port = Number(process.env.MCP_PORT) || 3000;
392
+ app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
393
+ }
394
+ else {
395
+ const transport = new StdioServerTransport();
396
+ await server.connect(transport);
177
397
  }
178
- const transport = new StdioServerTransport();
179
- await server.connect(transport);
180
398
  }
181
399
  main().catch(console.error);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codespar/mcp-zenvia",
3
- "version": "0.1.0",
4
- "description": "MCP server for Zenvia — SMS, WhatsApp, RCS messaging and templates",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for Zenvia — SMS, WhatsApp, RCS, Email, Voice, Facebook messaging, templates and reports",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "bin": {
@@ -25,6 +25,10 @@
25
25
  "sms",
26
26
  "whatsapp",
27
27
  "rcs",
28
+ "email",
29
+ "voice",
30
+ "facebook",
28
31
  "brazil"
29
- ]
32
+ ],
33
+ "mcpName": "io.github.codespar/mcp-zenvia"
30
34
  }
package/server.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.codespar/mcp-zenvia",
4
+ "description": "MCP server for Zenvia — SMS, WhatsApp, RCS, Email, Voice, Facebook messaging, templates and reports",
5
+ "repository": {
6
+ "url": "https://github.com/codespar/mcp-dev-brasil",
7
+ "source": "github",
8
+ "subfolder": "packages/communication/zenvia"
9
+ },
10
+ "version": "0.2.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "identifier": "@codespar/mcp-zenvia",
15
+ "version": "0.2.0",
16
+ "transport": {
17
+ "type": "stdio"
18
+ },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "ZENVIA_API_TOKEN",
22
+ "description": "API key for zenvia",
23
+ "isRequired": true,
24
+ "format": "string",
25
+ "isSecret": true
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ let listToolsHandler: Function;
4
+ let callToolHandler: Function;
5
+
6
+ vi.mock("@modelcontextprotocol/sdk/server/index.js", () => {
7
+ class FakeServer {
8
+ constructor() {}
9
+ setRequestHandler(schema: any, handler: Function) {
10
+ if (JSON.stringify(schema).includes("tools/list")) listToolsHandler = handler;
11
+ if (JSON.stringify(schema).includes("tools/call")) callToolHandler = handler;
12
+ }
13
+ connect() { return Promise.resolve(); }
14
+ }
15
+ return { Server: FakeServer };
16
+ });
17
+
18
+ vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ StdioServerTransport: class {} }));
19
+
20
+ process.env.ZENVIA_API_TOKEN = "test-token";
21
+
22
+ const mockFetch = vi.fn();
23
+ global.fetch = mockFetch as any;
24
+
25
+ beforeEach(async () => {
26
+ vi.resetModules();
27
+ listToolsHandler = undefined as any;
28
+ callToolHandler = undefined as any;
29
+ mockFetch.mockReset();
30
+ global.fetch = mockFetch as any;
31
+ await import("../index.js");
32
+ });
33
+
34
+ describe("mcp-zenvia", () => {
35
+ it("should register 8 tools", async () => {
36
+ const result = await listToolsHandler();
37
+ expect(result.tools).toHaveLength(8);
38
+ });
39
+
40
+ it("should call correct API endpoint for send_sms", async () => {
41
+ mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "msg1" }) });
42
+
43
+ await callToolHandler({
44
+ params: { name: "send_sms", arguments: { from: "sender", to: "5511999999999", text: "Hello" } },
45
+ });
46
+
47
+ const [url, opts] = mockFetch.mock.calls[0];
48
+ expect(url).toContain("api.zenvia.com/v2/channels/sms/messages");
49
+ expect(opts.method).toBe("POST");
50
+ expect(opts.headers["X-API-TOKEN"]).toBe("test-token");
51
+ });
52
+ });
package/src/index.ts CHANGED
@@ -1,17 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * MCP Server for Zenvia — multi-channel messaging (SMS, WhatsApp, RCS).
4
+ * MCP Server for Zenvia — multi-channel messaging (SMS, WhatsApp, RCS, Email, Voice, Facebook).
5
5
  *
6
6
  * Tools:
7
7
  * - send_sms: Send an SMS message
8
8
  * - send_whatsapp: Send a WhatsApp message
9
9
  * - send_rcs: Send an RCS message
10
+ * - send_email: Send a transactional email
11
+ * - send_voice: Send a voice message (TTS or pre-recorded audio)
12
+ * - send_facebook_message: Send a Facebook Messenger message
10
13
  * - get_message_status: Get message delivery status
11
14
  * - list_channels: List available messaging channels
12
15
  * - create_subscription: Create a webhook subscription for events
16
+ * - list_subscriptions: List all webhook subscriptions
17
+ * - delete_subscription: Delete a webhook subscription
13
18
  * - list_contacts: List contacts
19
+ * - create_contact: Create a contact in the contact base
20
+ * - delete_contact: Delete a contact
14
21
  * - send_template: Send a WhatsApp template message
22
+ * - list_templates: List approved WhatsApp templates
23
+ * - get_report_entries: Get message report entries by date range
24
+ * - add_opt_out: Add a phone number to the opt-out list
15
25
  *
16
26
  * Environment:
17
27
  * ZENVIA_API_TOKEN — API token from https://app.zenvia.com/
@@ -19,6 +29,8 @@
19
29
 
20
30
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
33
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
22
34
  import {
23
35
  CallToolRequestSchema,
24
36
  ListToolsRequestSchema,
@@ -40,11 +52,13 @@ async function zenviaRequest(method: string, path: string, body?: unknown): Prom
40
52
  const err = await res.text();
41
53
  throw new Error(`Zenvia API ${res.status}: ${err}`);
42
54
  }
43
- return res.json();
55
+ // Some endpoints (DELETE) return empty
56
+ const text = await res.text();
57
+ return text ? JSON.parse(text) : { ok: true };
44
58
  }
45
59
 
46
60
  const server = new Server(
47
- { name: "mcp-zenvia", version: "0.1.0" },
61
+ { name: "mcp-zenvia", version: "0.2.0" },
48
62
  { capabilities: { tools: {} } }
49
63
  );
50
64
 
@@ -89,6 +103,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
89
103
  required: ["from", "to", "text"],
90
104
  },
91
105
  },
106
+ {
107
+ name: "send_email",
108
+ description: "Send a transactional email",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ from: { type: "string", description: "Sender email address (verified domain)" },
113
+ to: { type: "string", description: "Recipient email address" },
114
+ subject: { type: "string", description: "Email subject" },
115
+ html: { type: "string", description: "HTML body of the email" },
116
+ text: { type: "string", description: "Plain text body (fallback)" },
117
+ },
118
+ required: ["from", "to", "subject"],
119
+ },
120
+ },
121
+ {
122
+ name: "send_voice",
123
+ description: "Send a voice message via TTS or pre-recorded audio URL",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ from: { type: "string", description: "Sender ID (Voice channel)" },
128
+ to: { type: "string", description: "Recipient phone number with country code" },
129
+ text: { type: "string", description: "Text to be spoken (TTS) — use either text or audioUrl" },
130
+ audioUrl: { type: "string", description: "URL of pre-recorded audio file — use either text or audioUrl" },
131
+ },
132
+ required: ["from", "to"],
133
+ },
134
+ },
135
+ {
136
+ name: "send_facebook_message",
137
+ description: "Send a Facebook Messenger message",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ from: { type: "string", description: "Sender ID (Facebook page)" },
142
+ to: { type: "string", description: "Recipient PSID (page-scoped user ID)" },
143
+ text: { type: "string", description: "Message text" },
144
+ },
145
+ required: ["from", "to", "text"],
146
+ },
147
+ },
92
148
  {
93
149
  name: "get_message_status",
94
150
  description: "Get message delivery status by ID",
@@ -112,12 +168,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
112
168
  type: "object",
113
169
  properties: {
114
170
  url: { type: "string", description: "Webhook URL to receive events" },
115
- channel: { type: "string", enum: ["sms", "whatsapp", "rcs"], description: "Channel to subscribe to" },
171
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs", "email", "voice", "facebook"], description: "Channel to subscribe to" },
116
172
  eventType: { type: "string", enum: ["MESSAGE", "MESSAGE_STATUS"], description: "Event type" },
117
173
  },
118
174
  required: ["url", "channel", "eventType"],
119
175
  },
120
176
  },
177
+ {
178
+ name: "list_subscriptions",
179
+ description: "List all webhook subscriptions",
180
+ inputSchema: { type: "object", properties: {} },
181
+ },
182
+ {
183
+ name: "delete_subscription",
184
+ description: "Delete a webhook subscription by ID",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ id: { type: "string", description: "Subscription ID" },
189
+ },
190
+ required: ["id"],
191
+ },
192
+ },
121
193
  {
122
194
  name: "list_contacts",
123
195
  description: "List contacts from the contact base",
@@ -129,6 +201,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
129
201
  },
130
202
  },
131
203
  },
204
+ {
205
+ name: "create_contact",
206
+ description: "Create a contact in the contact base",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ name: { type: "string", description: "Contact full name" },
211
+ phone: { type: "string", description: "Phone number with country code" },
212
+ email: { type: "string", description: "Email address" },
213
+ groupId: { type: "string", description: "Optional group ID to add the contact to" },
214
+ },
215
+ required: ["name"],
216
+ },
217
+ },
218
+ {
219
+ name: "delete_contact",
220
+ description: "Delete a contact by ID",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ id: { type: "string", description: "Contact ID" },
225
+ },
226
+ required: ["id"],
227
+ },
228
+ },
132
229
  {
133
230
  name: "send_template",
134
231
  description: "Send a WhatsApp template message (pre-approved)",
@@ -146,6 +243,42 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
146
243
  required: ["from", "to", "templateId"],
147
244
  },
148
245
  },
246
+ {
247
+ name: "list_templates",
248
+ description: "List approved message templates (WhatsApp/SMS/RCS)",
249
+ inputSchema: {
250
+ type: "object",
251
+ properties: {
252
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs"], description: "Filter templates by channel" },
253
+ },
254
+ },
255
+ },
256
+ {
257
+ name: "get_report_entries",
258
+ description: "Get message report entries within a date range",
259
+ inputSchema: {
260
+ type: "object",
261
+ properties: {
262
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs", "email", "voice", "facebook"], description: "Channel to report on" },
263
+ startDate: { type: "string", description: "ISO 8601 start date (e.g. 2026-04-01)" },
264
+ endDate: { type: "string", description: "ISO 8601 end date (e.g. 2026-04-24)" },
265
+ },
266
+ required: ["channel", "startDate", "endDate"],
267
+ },
268
+ },
269
+ {
270
+ name: "add_opt_out",
271
+ description: "Add a phone number to the opt-out list (suppresses future messages)",
272
+ inputSchema: {
273
+ type: "object",
274
+ properties: {
275
+ channel: { type: "string", enum: ["sms", "whatsapp", "rcs", "voice"], description: "Channel for the opt-out" },
276
+ from: { type: "string", description: "Sender ID the opt-out applies to" },
277
+ phone: { type: "string", description: "Phone number to opt out (with country code)" },
278
+ },
279
+ required: ["channel", "from", "phone"],
280
+ },
281
+ },
149
282
  ],
150
283
  }));
151
284
 
@@ -160,20 +293,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
160
293
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/whatsapp/messages", { from: args?.from, to: args?.to, contents: [{ type: "text", text: args?.text }] }), null, 2) }] };
161
294
  case "send_rcs":
162
295
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/rcs/messages", { from: args?.from, to: args?.to, contents: [{ type: "text", text: args?.text }] }), null, 2) }] };
296
+ case "send_email": {
297
+ const contents: any[] = [];
298
+ if (args?.html) contents.push({ type: "email", html: args.html, subject: args?.subject });
299
+ else if (args?.text) contents.push({ type: "email", text: args.text, subject: args?.subject });
300
+ else contents.push({ type: "email", subject: args?.subject });
301
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/email/messages", { from: args?.from, to: args?.to, contents }), null, 2) }] };
302
+ }
303
+ case "send_voice": {
304
+ const contents: any[] = [];
305
+ if (args?.audioUrl) contents.push({ type: "audio", url: args.audioUrl });
306
+ else if (args?.text) contents.push({ type: "text", text: args.text });
307
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/voice/messages", { from: args?.from, to: args?.to, contents }), null, 2) }] };
308
+ }
309
+ case "send_facebook_message":
310
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/facebook/messages", { from: args?.from, to: args?.to, contents: [{ type: "text", text: args?.text }] }), null, 2) }] };
163
311
  case "get_message_status":
164
312
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/reports/${args?.id}`), null, 2) }] };
165
313
  case "list_channels":
166
314
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", "/channels"), null, 2) }] };
167
315
  case "create_subscription":
168
316
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/subscriptions", { webhook: { url: args?.url }, criteria: { channel: args?.channel }, eventType: args?.eventType }), null, 2) }] };
317
+ case "list_subscriptions":
318
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", "/subscriptions"), null, 2) }] };
319
+ case "delete_subscription":
320
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("DELETE", `/subscriptions/${args?.id}`), null, 2) }] };
169
321
  case "list_contacts": {
170
322
  const params = new URLSearchParams();
171
323
  if (args?.page) params.set("page", String(args.page));
172
324
  if (args?.size) params.set("size", String(args.size));
173
325
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/contacts?${params}`), null, 2) }] };
174
326
  }
327
+ case "create_contact": {
328
+ const body: any = { name: args?.name };
329
+ if (args?.phone) body.phone = args.phone;
330
+ if (args?.email) body.email = args.email;
331
+ if (args?.groupId) body.groupId = args.groupId;
332
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/contacts", body), null, 2) }] };
333
+ }
334
+ case "delete_contact":
335
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("DELETE", `/contacts/${args?.id}`), null, 2) }] };
175
336
  case "send_template":
176
337
  return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", "/channels/whatsapp/messages", { from: args?.from, to: args?.to, contents: [{ type: "template", templateId: args?.templateId, fields: args?.fields || {} }] }), null, 2) }] };
338
+ case "list_templates": {
339
+ const params = new URLSearchParams();
340
+ if (args?.channel) params.set("channel", String(args.channel));
341
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/templates?${params}`), null, 2) }] };
342
+ }
343
+ case "get_report_entries": {
344
+ const params = new URLSearchParams();
345
+ params.set("startDate", String(args?.startDate));
346
+ params.set("endDate", String(args?.endDate));
347
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("GET", `/reports/${args?.channel}/entries?${params}`), null, 2) }] };
348
+ }
349
+ case "add_opt_out":
350
+ return { content: [{ type: "text", text: JSON.stringify(await zenviaRequest("POST", `/channels/${args?.channel}/senders/${args?.from}/opt-outs`, { phone: args?.phone }), null, 2) }] };
177
351
  default:
178
352
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
179
353
  }
@@ -183,12 +357,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
183
357
  });
184
358
 
185
359
  async function main() {
186
- if (!API_TOKEN) {
187
- console.error("ZENVIA_API_TOKEN environment variable is required");
188
- process.exit(1);
360
+ if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
361
+ const { default: express } = await import("express");
362
+ const { randomUUID } = await import("node:crypto");
363
+ const app = express();
364
+ app.use(express.json());
365
+ const transports = new Map<string, StreamableHTTPServerTransport>();
366
+ app.get("/health", (_req: any, res: any) => res.json({ status: "ok", sessions: transports.size }));
367
+ app.post("/mcp", async (req: any, res: any) => {
368
+ const sid = req.headers["mcp-session-id"] as string | undefined;
369
+ if (sid && transports.has(sid)) { await transports.get(sid)!.handleRequest(req, res, req.body); return; }
370
+ if (!sid && isInitializeRequest(req.body)) {
371
+ const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
372
+ t.onclose = () => { if (t.sessionId) transports.delete(t.sessionId); };
373
+ const s = new Server({ name: "mcp-zenvia", version: "0.2.0" }, { capabilities: { tools: {} } }); (server as any)._requestHandlers.forEach((v: any, k: any) => (s as any)._requestHandlers.set(k, v)); (server as any)._notificationHandlers?.forEach((v: any, k: any) => (s as any)._notificationHandlers.set(k, v)); await s.connect(t);
374
+ await t.handleRequest(req, res, req.body); return;
375
+ }
376
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
377
+ });
378
+ app.get("/mcp", async (req: any, res: any) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req, res); else res.status(400).send("Invalid session"); });
379
+ app.delete("/mcp", async (req: any, res: any) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req, res); else res.status(400).send("Invalid session"); });
380
+ const port = Number(process.env.MCP_PORT) || 3000;
381
+ app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
382
+ } else {
383
+ const transport = new StdioServerTransport();
384
+ await server.connect(transport);
189
385
  }
190
- const transport = new StdioServerTransport();
191
- await server.connect(transport);
192
386
  }
193
387
 
194
388
  main().catch(console.error);
package/tsconfig.json CHANGED
@@ -6,6 +6,7 @@
6
6
  "outDir": "./dist",
7
7
  "rootDir": "./src",
8
8
  "strict": true,
9
+ "skipLibCheck": true,
9
10
  "esModuleInterop": true,
10
11
  "declaration": true
11
12
  },