@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 +87 -0
- package/dist/index.js +402 -0
- package/package.json +36 -0
- package/server.json +37 -0
- package/src/index.ts +389 -0
- package/tsconfig.json +14 -0
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
|
+
}
|