@codespar/mcp-sendgrid 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 ADDED
@@ -0,0 +1,87 @@
1
+ # @codespar/mcp-sendgrid
2
+
3
+ MCP server for [SendGrid](https://sendgrid.com) — global transactional and marketing email.
4
+
5
+ SendGrid is Twilio-owned (acquired 2019). Together with [`@codespar/mcp-twilio`](../twilio) this package closes the messaging loop:
6
+
7
+ - **Twilio** — SMS, WhatsApp, Voice, Verify
8
+ - **SendGrid** — email (transactional + marketing)
9
+
10
+ Agents building commerce notification flows — order confirmations, shipping updates, abandoned-cart nudges, promos — can now cover every channel through two packages.
11
+
12
+ ## Tools
13
+
14
+ | Tool | Purpose |
15
+ |------|---------|
16
+ | `send_mail` | `POST /mail/send` — personalizations, content, attachments, scheduling |
17
+ | `send_template` | `POST /mail/send` using a dynamic template (`d-...`) — convenience wrapper |
18
+ | `add_contact` | `PUT /marketing/contacts` — upsert contacts (async job), assign to lists |
19
+ | `list_contacts` | `GET /marketing/contacts` — sample of contacts |
20
+ | `delete_contact` | `DELETE /marketing/contacts?ids=...` — delete by id or wipe all |
21
+ | `search_contacts` | `POST /marketing/contacts/search` — SGQL (SendGrid SQL-like) query |
22
+ | `list_templates` | `GET /templates` — dynamic (default) or legacy transactional templates |
23
+ | `create_template` | `POST /templates` — create a transactional template |
24
+ | `list_suppressions` | `GET /asm/groups/{group_id}/suppressions` — suppressed emails for a group |
25
+ | `add_suppression` | `POST /asm/groups/{group_id}/suppressions` — add suppressions |
26
+ | `get_stats` | `GET /stats` — sent / delivered / opens / clicks aggregated by day/week/month |
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install @codespar/mcp-sendgrid
32
+ ```
33
+
34
+ ## Environment
35
+
36
+ ```bash
37
+ SENDGRID_API_KEY="SG...." # required (secret)
38
+ SENDGRID_FROM_EMAIL="no-reply@yourdomain.com" # optional default sender
39
+ ```
40
+
41
+ The `SENDGRID_FROM_EMAIL` must be either a Verified Sender or belong to an authenticated domain.
42
+
43
+ ## Authentication
44
+
45
+ Bearer-token auth. The server handles this automatically.
46
+
47
+ ```
48
+ Authorization: Bearer <SENDGRID_API_KEY>
49
+ ```
50
+
51
+ ## API surface
52
+
53
+ - Base URL: `https://api.sendgrid.com/v3`
54
+ - All requests/responses are `application/json`
55
+ - `POST /mail/send` returns `202 Accepted` on success (no body)
56
+
57
+ ## Send with a dynamic template
58
+
59
+ ```json
60
+ {
61
+ "to": "buyer@example.com",
62
+ "template_id": "d-abc123...",
63
+ "dynamic_template_data": {
64
+ "order_id": "1001",
65
+ "total_brl": "R$ 249,90",
66
+ "tracking_url": "https://example.com/track/1001"
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Run
72
+
73
+ ```bash
74
+ # stdio (default — for Claude Desktop, Cursor, etc)
75
+ npx @codespar/mcp-sendgrid
76
+
77
+ # HTTP (for server-to-server testing)
78
+ MCP_HTTP=true MCP_PORT=3000 npx @codespar/mcp-sendgrid
79
+ ```
80
+
81
+ ## Pairs with
82
+
83
+ - [`@codespar/mcp-twilio`](../twilio) — SMS, WhatsApp, Voice, Verify, Lookup
84
+
85
+ ## License
86
+
87
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Server for SendGrid — global transactional + marketing email.
4
+ *
5
+ * SendGrid is Twilio-owned (acquired 2019). Together with @codespar/mcp-twilio
6
+ * this package closes the messaging loop for agents:
7
+ * - Twilio → SMS, WhatsApp, Voice, Verify
8
+ * - SendGrid → email (transactional + marketing)
9
+ *
10
+ * One server covering SendGrid's most-used surfaces:
11
+ * - Mail Send (v3 /mail/send, including dynamic templates)
12
+ * - Marketing Campaigns contacts (add / list / delete / search)
13
+ * - Transactional templates (list / create)
14
+ * - Suppressions per unsubscribe group (list / add)
15
+ * - Global stats (sent / delivered / opens / clicks)
16
+ *
17
+ * Tools (11):
18
+ * send_mail — POST /mail/send (personalizations, content, attachments)
19
+ * send_template — POST /mail/send using a dynamic template_id
20
+ * add_contact — PUT /marketing/contacts (upsert, async job)
21
+ * list_contacts — GET /marketing/contacts
22
+ * delete_contact — DELETE /marketing/contacts?ids=...
23
+ * search_contacts — POST /marketing/contacts/search (SGQL)
24
+ * list_templates — GET /templates?generations=dynamic
25
+ * create_template — POST /templates
26
+ * list_suppressions — GET /asm/groups/{group_id}/suppressions
27
+ * add_suppression — POST /asm/groups/{group_id}/suppressions
28
+ * get_stats — GET /stats?start_date=X&end_date=Y
29
+ *
30
+ * Authentication
31
+ * Authorization: Bearer <SENDGRID_API_KEY>
32
+ *
33
+ * API surface
34
+ * Base URL: https://api.sendgrid.com/v3
35
+ * Request/response: application/json
36
+ *
37
+ * Environment
38
+ * SENDGRID_API_KEY required — API key (secret)
39
+ * SENDGRID_FROM_EMAIL optional — default `from.email` for send_mail / send_template
40
+ *
41
+ * Docs: https://www.twilio.com/docs/sendgrid/api-reference
42
+ */
43
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
44
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
45
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
46
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
47
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
48
+ const API_KEY = process.env.SENDGRID_API_KEY || "";
49
+ const DEFAULT_FROM_EMAIL = process.env.SENDGRID_FROM_EMAIL || "";
50
+ const BASE_URL = "https://api.sendgrid.com/v3";
51
+ async function sendgridRequest(method, path, body) {
52
+ const url = `${BASE_URL}${path}`;
53
+ const headers = {
54
+ "Authorization": `Bearer ${API_KEY}`,
55
+ "Accept": "application/json",
56
+ };
57
+ let encodedBody;
58
+ if (body !== undefined && body !== null) {
59
+ encodedBody = JSON.stringify(body);
60
+ headers["Content-Type"] = "application/json";
61
+ }
62
+ const res = await fetch(url, { method, headers, body: encodedBody });
63
+ if (!res.ok) {
64
+ throw new Error(`SendGrid API ${res.status}: ${await res.text()}`);
65
+ }
66
+ const text = await res.text();
67
+ if (!text)
68
+ return { status: res.status };
69
+ try {
70
+ return JSON.parse(text);
71
+ }
72
+ catch {
73
+ return { raw: text, status: res.status };
74
+ }
75
+ }
76
+ function buildQuery(params) {
77
+ const q = new URLSearchParams();
78
+ for (const [k, v] of Object.entries(params)) {
79
+ if (v === undefined || v === null || v === "")
80
+ continue;
81
+ q.set(k, String(v));
82
+ }
83
+ const s = q.toString();
84
+ return s ? `?${s}` : "";
85
+ }
86
+ const server = new Server({ name: "mcp-sendgrid", version: "0.1.0" }, { capabilities: { tools: {} } });
87
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
88
+ tools: [
89
+ {
90
+ name: "send_mail",
91
+ description: "Send an email via POST /mail/send. Supply at least one `personalization` with `to` recipients, a `from` address (falls back to SENDGRID_FROM_EMAIL), and either `content` blocks or a `template_id` with `dynamic_template_data`. Returns 202 on success.",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ personalizations: {
96
+ type: "array",
97
+ description: "Array of personalization objects. Each has `to` (array of {email,name}), optional `cc`, `bcc`, `subject`, `dynamic_template_data`, `substitutions`.",
98
+ items: { type: "object" },
99
+ },
100
+ from: {
101
+ type: "object",
102
+ description: "Sender. { email, name? }. If omitted, SENDGRID_FROM_EMAIL is used.",
103
+ },
104
+ reply_to: { type: "object", description: "Optional reply-to. { email, name? }" },
105
+ subject: { type: "string", description: "Global subject (overridden by personalization.subject)" },
106
+ content: {
107
+ type: "array",
108
+ description: "Content blocks: [{ type: 'text/plain' | 'text/html', value }]. Omit if using template_id.",
109
+ items: { type: "object" },
110
+ },
111
+ attachments: {
112
+ type: "array",
113
+ description: "Attachments: [{ content (base64), type, filename, disposition?, content_id? }]",
114
+ items: { type: "object" },
115
+ },
116
+ template_id: { type: "string", description: "Dynamic template id (starts with `d-`). Use with dynamic_template_data on each personalization." },
117
+ categories: { type: "array", items: { type: "string" }, description: "Up to 10 category tags for analytics" },
118
+ send_at: { type: "number", description: "Unix timestamp to schedule send (must be within 72h)" },
119
+ asm: { type: "object", description: "Unsubscribe group settings: { group_id, groups_to_display? }" },
120
+ mail_settings: { type: "object", description: "e.g. { sandbox_mode: { enable: true } }" },
121
+ tracking_settings: { type: "object", description: "Click / open / subscription tracking config" },
122
+ },
123
+ required: ["personalizations"],
124
+ },
125
+ },
126
+ {
127
+ name: "send_template",
128
+ description: "Convenience wrapper for POST /mail/send with a dynamic template. Equivalent to send_mail with `template_id` set. Supply `to`, `template_id`, and `dynamic_template_data`; content is rendered from the template.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ to: { type: "string", description: "Recipient email (single address). Use send_mail for multiple/complex personalizations." },
133
+ to_name: { type: "string", description: "Optional recipient display name" },
134
+ from: { type: "object", description: "Sender { email, name? }. Falls back to SENDGRID_FROM_EMAIL." },
135
+ template_id: { type: "string", description: "Dynamic template id (starts with `d-`)" },
136
+ dynamic_template_data: { type: "object", description: "Handlebars variables substituted into the template" },
137
+ subject: { type: "string", description: "Optional subject override (usually set inside the template)" },
138
+ },
139
+ required: ["to", "template_id"],
140
+ },
141
+ },
142
+ {
143
+ name: "add_contact",
144
+ description: "Upsert contacts in Marketing Campaigns via PUT /marketing/contacts. Matches on email. Returns a job_id — ingestion is async. Optionally assign to list_ids.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ list_ids: { type: "array", items: { type: "string" }, description: "Optional list UUIDs to add these contacts to" },
149
+ contacts: {
150
+ type: "array",
151
+ description: "Contacts to upsert. Each: { email (required), first_name?, last_name?, phone_number?, country?, city?, custom_fields? }",
152
+ items: { type: "object" },
153
+ },
154
+ },
155
+ required: ["contacts"],
156
+ },
157
+ },
158
+ {
159
+ name: "list_contacts",
160
+ description: "List Marketing Campaigns contacts via GET /marketing/contacts. Returns up to 50 sample contacts; for full export use a Contacts Export job.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {},
164
+ },
165
+ },
166
+ {
167
+ name: "delete_contact",
168
+ description: "Delete contacts by id via DELETE /marketing/contacts?ids=.... Pass a comma-separated list of contact UUIDs, or set delete_all_contacts=true to wipe all contacts (irreversible).",
169
+ inputSchema: {
170
+ type: "object",
171
+ properties: {
172
+ ids: { type: "string", description: "Comma-separated list of contact UUIDs to delete" },
173
+ delete_all_contacts: { type: "boolean", description: "If true, deletes ALL contacts. Ignores `ids`." },
174
+ },
175
+ },
176
+ },
177
+ {
178
+ name: "search_contacts",
179
+ description: "Search contacts with an SGQL query via POST /marketing/contacts/search. Example: `email LIKE '%@codespar.com' AND CONTAINS(list_ids, 'abc-123')`.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ query: { type: "string", description: "SGQL WHERE clause (SendGrid SQL-like syntax)" },
184
+ },
185
+ required: ["query"],
186
+ },
187
+ },
188
+ {
189
+ name: "list_templates",
190
+ description: "List transactional templates via GET /templates. By default returns dynamic templates (recommended); set generations='legacy' for legacy.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ generations: { type: "string", enum: ["dynamic", "legacy", "legacy,dynamic"], description: "Template generation filter (default `dynamic`)" },
195
+ page_size: { type: "number", description: "Results per page (1-200, default 10)" },
196
+ page_token: { type: "string", description: "Pagination token from previous response" },
197
+ },
198
+ },
199
+ },
200
+ {
201
+ name: "create_template",
202
+ description: "Create a transactional template via POST /templates. Returns a template_id. Add versions separately via /templates/{id}/versions.",
203
+ inputSchema: {
204
+ type: "object",
205
+ properties: {
206
+ name: { type: "string", description: "Template name (max 100 chars)" },
207
+ generation: { type: "string", enum: ["dynamic", "legacy"], description: "Template generation (recommend `dynamic`)" },
208
+ },
209
+ required: ["name"],
210
+ },
211
+ },
212
+ {
213
+ name: "list_suppressions",
214
+ description: "List all suppressed recipients for an unsubscribe group via GET /asm/groups/{group_id}/suppressions. Returns an array of email strings.",
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: {
218
+ group_id: { type: "number", description: "Unsubscribe group id" },
219
+ },
220
+ required: ["group_id"],
221
+ },
222
+ },
223
+ {
224
+ name: "add_suppression",
225
+ description: "Add recipients to a suppression group via POST /asm/groups/{group_id}/suppressions. Future mail in this group will be blocked for these addresses.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ group_id: { type: "number", description: "Unsubscribe group id" },
230
+ recipient_emails: { type: "array", items: { type: "string" }, description: "Emails to suppress" },
231
+ },
232
+ required: ["group_id", "recipient_emails"],
233
+ },
234
+ },
235
+ {
236
+ name: "get_stats",
237
+ description: "Global email stats via GET /stats. Returns sent/delivered/opens/clicks/bounces/spam_reports aggregated between start_date and end_date.",
238
+ inputSchema: {
239
+ type: "object",
240
+ properties: {
241
+ start_date: { type: "string", description: "YYYY-MM-DD (required)" },
242
+ end_date: { type: "string", description: "YYYY-MM-DD (default: today)" },
243
+ aggregated_by: { type: "string", enum: ["day", "week", "month"], description: "Bucket size (default day)" },
244
+ categories: { type: "string", description: "Optional category filter (comma-separated if multiple)" },
245
+ },
246
+ required: ["start_date"],
247
+ },
248
+ },
249
+ ],
250
+ }));
251
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
252
+ const { name, arguments: args } = request.params;
253
+ const a = (args ?? {});
254
+ try {
255
+ switch (name) {
256
+ case "send_mail": {
257
+ const body = {
258
+ personalizations: a.personalizations,
259
+ };
260
+ if (a.from)
261
+ body.from = a.from;
262
+ else if (DEFAULT_FROM_EMAIL)
263
+ body.from = { email: DEFAULT_FROM_EMAIL };
264
+ if (a.reply_to)
265
+ body.reply_to = a.reply_to;
266
+ if (a.subject)
267
+ body.subject = a.subject;
268
+ if (a.content)
269
+ body.content = a.content;
270
+ if (a.attachments)
271
+ body.attachments = a.attachments;
272
+ if (a.template_id)
273
+ body.template_id = a.template_id;
274
+ if (a.categories)
275
+ body.categories = a.categories;
276
+ if (a.send_at)
277
+ body.send_at = a.send_at;
278
+ if (a.asm)
279
+ body.asm = a.asm;
280
+ if (a.mail_settings)
281
+ body.mail_settings = a.mail_settings;
282
+ if (a.tracking_settings)
283
+ body.tracking_settings = a.tracking_settings;
284
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/mail/send", body), null, 2) }] };
285
+ }
286
+ case "send_template": {
287
+ const to = { email: a.to };
288
+ if (a.to_name)
289
+ to.name = a.to_name;
290
+ const personalization = { to: [to] };
291
+ if (a.dynamic_template_data)
292
+ personalization.dynamic_template_data = a.dynamic_template_data;
293
+ if (a.subject)
294
+ personalization.subject = a.subject;
295
+ const body = {
296
+ personalizations: [personalization],
297
+ template_id: a.template_id,
298
+ };
299
+ if (a.from)
300
+ body.from = a.from;
301
+ else if (DEFAULT_FROM_EMAIL)
302
+ body.from = { email: DEFAULT_FROM_EMAIL };
303
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/mail/send", body), null, 2) }] };
304
+ }
305
+ case "add_contact": {
306
+ const body = { contacts: a.contacts };
307
+ if (a.list_ids)
308
+ body.list_ids = a.list_ids;
309
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("PUT", "/marketing/contacts", body), null, 2) }] };
310
+ }
311
+ case "list_contacts":
312
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", "/marketing/contacts"), null, 2) }] };
313
+ case "delete_contact": {
314
+ const q = buildQuery({
315
+ ids: a.ids,
316
+ delete_all_contacts: a.delete_all_contacts ? "true" : undefined,
317
+ });
318
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("DELETE", `/marketing/contacts${q}`), null, 2) }] };
319
+ }
320
+ case "search_contacts":
321
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/marketing/contacts/search", { query: a.query }), null, 2) }] };
322
+ case "list_templates": {
323
+ const q = buildQuery({
324
+ generations: a.generations ?? "dynamic",
325
+ page_size: a.page_size,
326
+ page_token: a.page_token,
327
+ });
328
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", `/templates${q}`), null, 2) }] };
329
+ }
330
+ case "create_template": {
331
+ const body = { name: a.name };
332
+ if (a.generation)
333
+ body.generation = a.generation;
334
+ else
335
+ body.generation = "dynamic";
336
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/templates", body), null, 2) }] };
337
+ }
338
+ case "list_suppressions":
339
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", `/asm/groups/${a.group_id}/suppressions`), null, 2) }] };
340
+ case "add_suppression":
341
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", `/asm/groups/${a.group_id}/suppressions`, { recipient_emails: a.recipient_emails }), null, 2) }] };
342
+ case "get_stats": {
343
+ const q = buildQuery({
344
+ start_date: a.start_date,
345
+ end_date: a.end_date,
346
+ aggregated_by: a.aggregated_by,
347
+ categories: a.categories,
348
+ });
349
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", `/stats${q}`), null, 2) }] };
350
+ }
351
+ default:
352
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
353
+ }
354
+ }
355
+ catch (err) {
356
+ return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
357
+ }
358
+ });
359
+ async function main() {
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();
366
+ app.get("/health", (_req, res) => res.json({ status: "ok", sessions: transports.size }));
367
+ app.post("/mcp", async (req, res) => {
368
+ const sid = req.headers["mcp-session-id"];
369
+ if (sid && transports.has(sid)) {
370
+ await transports.get(sid).handleRequest(req, res, req.body);
371
+ return;
372
+ }
373
+ if (!sid && isInitializeRequest(req.body)) {
374
+ const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
375
+ t.onclose = () => { if (t.sessionId)
376
+ transports.delete(t.sessionId); };
377
+ const s = new Server({ name: "mcp-sendgrid", version: "0.1.0" }, { capabilities: { tools: {} } });
378
+ server._requestHandlers.forEach((v, k) => s._requestHandlers.set(k, v));
379
+ server._notificationHandlers?.forEach((v, k) => s._notificationHandlers.set(k, v));
380
+ await s.connect(t);
381
+ await t.handleRequest(req, res, req.body);
382
+ return;
383
+ }
384
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
385
+ });
386
+ app.get("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
387
+ await transports.get(sid).handleRequest(req, res);
388
+ else
389
+ res.status(400).send("Invalid session"); });
390
+ app.delete("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
391
+ await transports.get(sid).handleRequest(req, res);
392
+ else
393
+ res.status(400).send("Invalid session"); });
394
+ const port = Number(process.env.MCP_PORT) || 3000;
395
+ app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
396
+ }
397
+ else {
398
+ const transport = new StdioServerTransport();
399
+ await server.connect(transport);
400
+ }
401
+ }
402
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@codespar/mcp-sendgrid",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for SendGrid — global transactional + marketing email (Twilio-owned). Pairs with @codespar/mcp-twilio for full messaging coverage.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "mcp-sendgrid": "./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
+ "sendgrid",
25
+ "twilio",
26
+ "email",
27
+ "transactional-email",
28
+ "marketing-email",
29
+ "smtp",
30
+ "contacts",
31
+ "templates",
32
+ "suppressions",
33
+ "communication"
34
+ ],
35
+ "mcpName": "io.github.codespar/mcp-sendgrid"
36
+ }
package/server.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.codespar/mcp-sendgrid",
4
+ "description": "MCP server for SendGrid — global transactional + marketing email (Twilio-owned). Pairs with @codespar/mcp-twilio for full messaging coverage.",
5
+ "repository": {
6
+ "url": "https://github.com/codespar/mcp-dev-brasil",
7
+ "source": "github",
8
+ "subfolder": "packages/communication/sendgrid"
9
+ },
10
+ "version": "0.1.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "identifier": "@codespar/mcp-sendgrid",
15
+ "version": "0.1.0",
16
+ "transport": {
17
+ "type": "stdio"
18
+ },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "SENDGRID_API_KEY",
22
+ "description": "SendGrid API key. Used as Bearer token in the Authorization header.",
23
+ "isRequired": true,
24
+ "format": "string",
25
+ "isSecret": true
26
+ },
27
+ {
28
+ "name": "SENDGRID_FROM_EMAIL",
29
+ "description": "Optional default From address used by send_mail / send_template when `from.email` is omitted. Must be a Verified Sender or authenticated domain.",
30
+ "isRequired": false,
31
+ "format": "string",
32
+ "isSecret": false
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MCP Server for SendGrid — global transactional + marketing email.
5
+ *
6
+ * SendGrid is Twilio-owned (acquired 2019). Together with @codespar/mcp-twilio
7
+ * this package closes the messaging loop for agents:
8
+ * - Twilio → SMS, WhatsApp, Voice, Verify
9
+ * - SendGrid → email (transactional + marketing)
10
+ *
11
+ * One server covering SendGrid's most-used surfaces:
12
+ * - Mail Send (v3 /mail/send, including dynamic templates)
13
+ * - Marketing Campaigns contacts (add / list / delete / search)
14
+ * - Transactional templates (list / create)
15
+ * - Suppressions per unsubscribe group (list / add)
16
+ * - Global stats (sent / delivered / opens / clicks)
17
+ *
18
+ * Tools (11):
19
+ * send_mail — POST /mail/send (personalizations, content, attachments)
20
+ * send_template — POST /mail/send using a dynamic template_id
21
+ * add_contact — PUT /marketing/contacts (upsert, async job)
22
+ * list_contacts — GET /marketing/contacts
23
+ * delete_contact — DELETE /marketing/contacts?ids=...
24
+ * search_contacts — POST /marketing/contacts/search (SGQL)
25
+ * list_templates — GET /templates?generations=dynamic
26
+ * create_template — POST /templates
27
+ * list_suppressions — GET /asm/groups/{group_id}/suppressions
28
+ * add_suppression — POST /asm/groups/{group_id}/suppressions
29
+ * get_stats — GET /stats?start_date=X&end_date=Y
30
+ *
31
+ * Authentication
32
+ * Authorization: Bearer <SENDGRID_API_KEY>
33
+ *
34
+ * API surface
35
+ * Base URL: https://api.sendgrid.com/v3
36
+ * Request/response: application/json
37
+ *
38
+ * Environment
39
+ * SENDGRID_API_KEY required — API key (secret)
40
+ * SENDGRID_FROM_EMAIL optional — default `from.email` for send_mail / send_template
41
+ *
42
+ * Docs: https://www.twilio.com/docs/sendgrid/api-reference
43
+ */
44
+
45
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
46
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
47
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
48
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
49
+ import {
50
+ CallToolRequestSchema,
51
+ ListToolsRequestSchema,
52
+ } from "@modelcontextprotocol/sdk/types.js";
53
+
54
+ const API_KEY = process.env.SENDGRID_API_KEY || "";
55
+ const DEFAULT_FROM_EMAIL = process.env.SENDGRID_FROM_EMAIL || "";
56
+ const BASE_URL = "https://api.sendgrid.com/v3";
57
+
58
+ async function sendgridRequest(
59
+ method: string,
60
+ path: string,
61
+ body?: unknown
62
+ ): Promise<unknown> {
63
+ const url = `${BASE_URL}${path}`;
64
+ const headers: Record<string, string> = {
65
+ "Authorization": `Bearer ${API_KEY}`,
66
+ "Accept": "application/json",
67
+ };
68
+
69
+ let encodedBody: string | undefined;
70
+ if (body !== undefined && body !== null) {
71
+ encodedBody = JSON.stringify(body);
72
+ headers["Content-Type"] = "application/json";
73
+ }
74
+
75
+ const res = await fetch(url, { method, headers, body: encodedBody });
76
+ if (!res.ok) {
77
+ throw new Error(`SendGrid API ${res.status}: ${await res.text()}`);
78
+ }
79
+ const text = await res.text();
80
+ if (!text) return { status: res.status };
81
+ try {
82
+ return JSON.parse(text);
83
+ } catch {
84
+ return { raw: text, status: res.status };
85
+ }
86
+ }
87
+
88
+ function buildQuery(params: Record<string, unknown>): string {
89
+ const q = new URLSearchParams();
90
+ for (const [k, v] of Object.entries(params)) {
91
+ if (v === undefined || v === null || v === "") continue;
92
+ q.set(k, String(v));
93
+ }
94
+ const s = q.toString();
95
+ return s ? `?${s}` : "";
96
+ }
97
+
98
+ const server = new Server(
99
+ { name: "mcp-sendgrid", version: "0.1.0" },
100
+ { capabilities: { tools: {} } }
101
+ );
102
+
103
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
104
+ tools: [
105
+ {
106
+ name: "send_mail",
107
+ description: "Send an email via POST /mail/send. Supply at least one `personalization` with `to` recipients, a `from` address (falls back to SENDGRID_FROM_EMAIL), and either `content` blocks or a `template_id` with `dynamic_template_data`. Returns 202 on success.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ personalizations: {
112
+ type: "array",
113
+ description: "Array of personalization objects. Each has `to` (array of {email,name}), optional `cc`, `bcc`, `subject`, `dynamic_template_data`, `substitutions`.",
114
+ items: { type: "object" },
115
+ },
116
+ from: {
117
+ type: "object",
118
+ description: "Sender. { email, name? }. If omitted, SENDGRID_FROM_EMAIL is used.",
119
+ },
120
+ reply_to: { type: "object", description: "Optional reply-to. { email, name? }" },
121
+ subject: { type: "string", description: "Global subject (overridden by personalization.subject)" },
122
+ content: {
123
+ type: "array",
124
+ description: "Content blocks: [{ type: 'text/plain' | 'text/html', value }]. Omit if using template_id.",
125
+ items: { type: "object" },
126
+ },
127
+ attachments: {
128
+ type: "array",
129
+ description: "Attachments: [{ content (base64), type, filename, disposition?, content_id? }]",
130
+ items: { type: "object" },
131
+ },
132
+ template_id: { type: "string", description: "Dynamic template id (starts with `d-`). Use with dynamic_template_data on each personalization." },
133
+ categories: { type: "array", items: { type: "string" }, description: "Up to 10 category tags for analytics" },
134
+ send_at: { type: "number", description: "Unix timestamp to schedule send (must be within 72h)" },
135
+ asm: { type: "object", description: "Unsubscribe group settings: { group_id, groups_to_display? }" },
136
+ mail_settings: { type: "object", description: "e.g. { sandbox_mode: { enable: true } }" },
137
+ tracking_settings: { type: "object", description: "Click / open / subscription tracking config" },
138
+ },
139
+ required: ["personalizations"],
140
+ },
141
+ },
142
+ {
143
+ name: "send_template",
144
+ description: "Convenience wrapper for POST /mail/send with a dynamic template. Equivalent to send_mail with `template_id` set. Supply `to`, `template_id`, and `dynamic_template_data`; content is rendered from the template.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ to: { type: "string", description: "Recipient email (single address). Use send_mail for multiple/complex personalizations." },
149
+ to_name: { type: "string", description: "Optional recipient display name" },
150
+ from: { type: "object", description: "Sender { email, name? }. Falls back to SENDGRID_FROM_EMAIL." },
151
+ template_id: { type: "string", description: "Dynamic template id (starts with `d-`)" },
152
+ dynamic_template_data: { type: "object", description: "Handlebars variables substituted into the template" },
153
+ subject: { type: "string", description: "Optional subject override (usually set inside the template)" },
154
+ },
155
+ required: ["to", "template_id"],
156
+ },
157
+ },
158
+ {
159
+ name: "add_contact",
160
+ description: "Upsert contacts in Marketing Campaigns via PUT /marketing/contacts. Matches on email. Returns a job_id — ingestion is async. Optionally assign to list_ids.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ list_ids: { type: "array", items: { type: "string" }, description: "Optional list UUIDs to add these contacts to" },
165
+ contacts: {
166
+ type: "array",
167
+ description: "Contacts to upsert. Each: { email (required), first_name?, last_name?, phone_number?, country?, city?, custom_fields? }",
168
+ items: { type: "object" },
169
+ },
170
+ },
171
+ required: ["contacts"],
172
+ },
173
+ },
174
+ {
175
+ name: "list_contacts",
176
+ description: "List Marketing Campaigns contacts via GET /marketing/contacts. Returns up to 50 sample contacts; for full export use a Contacts Export job.",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {},
180
+ },
181
+ },
182
+ {
183
+ name: "delete_contact",
184
+ description: "Delete contacts by id via DELETE /marketing/contacts?ids=.... Pass a comma-separated list of contact UUIDs, or set delete_all_contacts=true to wipe all contacts (irreversible).",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ ids: { type: "string", description: "Comma-separated list of contact UUIDs to delete" },
189
+ delete_all_contacts: { type: "boolean", description: "If true, deletes ALL contacts. Ignores `ids`." },
190
+ },
191
+ },
192
+ },
193
+ {
194
+ name: "search_contacts",
195
+ description: "Search contacts with an SGQL query via POST /marketing/contacts/search. Example: `email LIKE '%@codespar.com' AND CONTAINS(list_ids, 'abc-123')`.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ query: { type: "string", description: "SGQL WHERE clause (SendGrid SQL-like syntax)" },
200
+ },
201
+ required: ["query"],
202
+ },
203
+ },
204
+ {
205
+ name: "list_templates",
206
+ description: "List transactional templates via GET /templates. By default returns dynamic templates (recommended); set generations='legacy' for legacy.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ generations: { type: "string", enum: ["dynamic", "legacy", "legacy,dynamic"], description: "Template generation filter (default `dynamic`)" },
211
+ page_size: { type: "number", description: "Results per page (1-200, default 10)" },
212
+ page_token: { type: "string", description: "Pagination token from previous response" },
213
+ },
214
+ },
215
+ },
216
+ {
217
+ name: "create_template",
218
+ description: "Create a transactional template via POST /templates. Returns a template_id. Add versions separately via /templates/{id}/versions.",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ name: { type: "string", description: "Template name (max 100 chars)" },
223
+ generation: { type: "string", enum: ["dynamic", "legacy"], description: "Template generation (recommend `dynamic`)" },
224
+ },
225
+ required: ["name"],
226
+ },
227
+ },
228
+ {
229
+ name: "list_suppressions",
230
+ description: "List all suppressed recipients for an unsubscribe group via GET /asm/groups/{group_id}/suppressions. Returns an array of email strings.",
231
+ inputSchema: {
232
+ type: "object",
233
+ properties: {
234
+ group_id: { type: "number", description: "Unsubscribe group id" },
235
+ },
236
+ required: ["group_id"],
237
+ },
238
+ },
239
+ {
240
+ name: "add_suppression",
241
+ description: "Add recipients to a suppression group via POST /asm/groups/{group_id}/suppressions. Future mail in this group will be blocked for these addresses.",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {
245
+ group_id: { type: "number", description: "Unsubscribe group id" },
246
+ recipient_emails: { type: "array", items: { type: "string" }, description: "Emails to suppress" },
247
+ },
248
+ required: ["group_id", "recipient_emails"],
249
+ },
250
+ },
251
+ {
252
+ name: "get_stats",
253
+ description: "Global email stats via GET /stats. Returns sent/delivered/opens/clicks/bounces/spam_reports aggregated between start_date and end_date.",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ start_date: { type: "string", description: "YYYY-MM-DD (required)" },
258
+ end_date: { type: "string", description: "YYYY-MM-DD (default: today)" },
259
+ aggregated_by: { type: "string", enum: ["day", "week", "month"], description: "Bucket size (default day)" },
260
+ categories: { type: "string", description: "Optional category filter (comma-separated if multiple)" },
261
+ },
262
+ required: ["start_date"],
263
+ },
264
+ },
265
+ ],
266
+ }));
267
+
268
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
269
+ const { name, arguments: args } = request.params;
270
+ const a = (args ?? {}) as Record<string, unknown>;
271
+
272
+ try {
273
+ switch (name) {
274
+ case "send_mail": {
275
+ const body: Record<string, unknown> = {
276
+ personalizations: a.personalizations,
277
+ };
278
+ if (a.from) body.from = a.from;
279
+ else if (DEFAULT_FROM_EMAIL) body.from = { email: DEFAULT_FROM_EMAIL };
280
+ if (a.reply_to) body.reply_to = a.reply_to;
281
+ if (a.subject) body.subject = a.subject;
282
+ if (a.content) body.content = a.content;
283
+ if (a.attachments) body.attachments = a.attachments;
284
+ if (a.template_id) body.template_id = a.template_id;
285
+ if (a.categories) body.categories = a.categories;
286
+ if (a.send_at) body.send_at = a.send_at;
287
+ if (a.asm) body.asm = a.asm;
288
+ if (a.mail_settings) body.mail_settings = a.mail_settings;
289
+ if (a.tracking_settings) body.tracking_settings = a.tracking_settings;
290
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/mail/send", body), null, 2) }] };
291
+ }
292
+ case "send_template": {
293
+ const to: Record<string, unknown> = { email: a.to };
294
+ if (a.to_name) to.name = a.to_name;
295
+ const personalization: Record<string, unknown> = { to: [to] };
296
+ if (a.dynamic_template_data) personalization.dynamic_template_data = a.dynamic_template_data;
297
+ if (a.subject) personalization.subject = a.subject;
298
+ const body: Record<string, unknown> = {
299
+ personalizations: [personalization],
300
+ template_id: a.template_id,
301
+ };
302
+ if (a.from) body.from = a.from;
303
+ else if (DEFAULT_FROM_EMAIL) body.from = { email: DEFAULT_FROM_EMAIL };
304
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/mail/send", body), null, 2) }] };
305
+ }
306
+ case "add_contact": {
307
+ const body: Record<string, unknown> = { contacts: a.contacts };
308
+ if (a.list_ids) body.list_ids = a.list_ids;
309
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("PUT", "/marketing/contacts", body), null, 2) }] };
310
+ }
311
+ case "list_contacts":
312
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", "/marketing/contacts"), null, 2) }] };
313
+ case "delete_contact": {
314
+ const q = buildQuery({
315
+ ids: a.ids,
316
+ delete_all_contacts: a.delete_all_contacts ? "true" : undefined,
317
+ });
318
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("DELETE", `/marketing/contacts${q}`), null, 2) }] };
319
+ }
320
+ case "search_contacts":
321
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/marketing/contacts/search", { query: a.query }), null, 2) }] };
322
+ case "list_templates": {
323
+ const q = buildQuery({
324
+ generations: a.generations ?? "dynamic",
325
+ page_size: a.page_size,
326
+ page_token: a.page_token,
327
+ });
328
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", `/templates${q}`), null, 2) }] };
329
+ }
330
+ case "create_template": {
331
+ const body: Record<string, unknown> = { name: a.name };
332
+ if (a.generation) body.generation = a.generation;
333
+ else body.generation = "dynamic";
334
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", "/templates", body), null, 2) }] };
335
+ }
336
+ case "list_suppressions":
337
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", `/asm/groups/${a.group_id}/suppressions`), null, 2) }] };
338
+ case "add_suppression":
339
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("POST", `/asm/groups/${a.group_id}/suppressions`, { recipient_emails: a.recipient_emails }), null, 2) }] };
340
+ case "get_stats": {
341
+ const q = buildQuery({
342
+ start_date: a.start_date,
343
+ end_date: a.end_date,
344
+ aggregated_by: a.aggregated_by,
345
+ categories: a.categories,
346
+ });
347
+ return { content: [{ type: "text", text: JSON.stringify(await sendgridRequest("GET", `/stats${q}`), null, 2) }] };
348
+ }
349
+ default:
350
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
351
+ }
352
+ } catch (err) {
353
+ return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
354
+ }
355
+ });
356
+
357
+ async function main() {
358
+ if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
359
+ const { default: express } = await import("express");
360
+ const { randomUUID } = await import("node:crypto");
361
+ const app = express();
362
+ app.use(express.json());
363
+ const transports = new Map<string, StreamableHTTPServerTransport>();
364
+ app.get("/health", (_req: unknown, res: { json: (body: unknown) => unknown }) => res.json({ status: "ok", sessions: transports.size }));
365
+ app.post("/mcp", async (req: { headers: Record<string, string | string[] | undefined>; body: unknown }, res: { status: (code: number) => { json: (body: unknown) => unknown } }) => {
366
+ const sid = req.headers["mcp-session-id"] as string | undefined;
367
+ if (sid && transports.has(sid)) { await transports.get(sid)!.handleRequest(req as never, res as never, req.body); return; }
368
+ if (!sid && isInitializeRequest(req.body)) {
369
+ const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
370
+ t.onclose = () => { if (t.sessionId) transports.delete(t.sessionId); };
371
+ const s = new Server({ name: "mcp-sendgrid", version: "0.1.0" }, { capabilities: { tools: {} } });
372
+ (server as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.forEach((v, k) => (s as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.set(k, v));
373
+ (server as unknown as { _notificationHandlers?: Map<unknown, unknown> })._notificationHandlers?.forEach((v, k) => (s as unknown as { _notificationHandlers: Map<unknown, unknown> })._notificationHandlers.set(k, v));
374
+ await s.connect(t);
375
+ await t.handleRequest(req as never, res as never, req.body); return;
376
+ }
377
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
378
+ });
379
+ 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"); });
380
+ 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"); });
381
+ const port = Number(process.env.MCP_PORT) || 3000;
382
+ app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
383
+ } else {
384
+ const transport = new StdioServerTransport();
385
+ await server.connect(transport);
386
+ }
387
+ }
388
+
389
+ 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
+ }