@supernova123/resend-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ # These funding sources support Nova MCP Servers
2
+ github: [nova]
3
+ # ko_fi: # Replace with a single Ko-fi username
4
+ # patreon: # Replace with a single Patreon username
5
+ # open_collective: # Replace with a single Open Collective username
6
+ # community_bridge: # Replace with a single Community Bridge project name
7
+ # custom: # Replace with up to 4 custom sponsorship URLs e.g., ["link1", "link2"]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nova
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ # Marketplace Listings — Resend MCP Server
2
+
3
+ ## Listing 1: mcp.so
4
+
5
+ **Title:** Resend MCP Server
6
+
7
+ **Category:** Email & Communication
8
+
9
+ **Description:**
10
+ MCP server for Resend — the modern transactional email API. Send emails, manage sending domains, create API keys, and manage audience contacts through natural language. Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible client.
11
+
12
+ Features:
13
+ - Send transactional emails (HTML or plain text, with Cc/Bcc/Reply-To)
14
+ - List and inspect sent emails with delivery status
15
+ - Create and verify sending domains
16
+ - Create and list API keys (full, sending, or domain-scoped)
17
+ - List and add contacts to audiences
18
+ - Rate-limited to 9 req/s, automatic retry on 429
19
+
20
+ **Tags:** resend, email, transactional, smtp, api, mcp, automation
21
+
22
+ **Installation:**
23
+ ```bash
24
+ npx -y resend-mcp-server
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Listing 2: Smithery (smithery.ai)
30
+
31
+ **Title:** Resend
32
+
33
+ **Description:**
34
+ Connect AI assistants to Resend's transactional email API. Send emails, manage domains, mint API keys, and manage audience contacts through the Model Context Protocol. Requires a Resend API key.
35
+
36
+ **Tools:** 10 (send_email, list_emails, get_email, create_domain, list_domains, verify_domain, create_api_key, list_api_keys, list_contacts, create_contact)
37
+
38
+ **Transport:** stdio
39
+
40
+ **Config:**
41
+ ```json
42
+ {
43
+ "command": "npx",
44
+ "args": ["-y", "resend-mcp-server"],
45
+ "env": {
46
+ "RESEND_API_KEY": "re_xxxxxxxxxxxx"
47
+ }
48
+ }
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Listing 3: Glama MCP
54
+
55
+ **Title:** Resend MCP Server
56
+
57
+ **Description:**
58
+ Access Resend's transactional email API through MCP. Send emails, manage sending domains, create API keys, and manage audience contacts. Works with Claude Desktop, Cursor, and other MCP clients. Requires a Resend API key.
59
+
60
+ **Category:** Communication
61
+
62
+ **Tools:** 10
63
+
64
+ **Tags:** resend, email, transactional, smtp, api, automation
65
+
66
+ ---
67
+
68
+ ## Listing 4: There's An AI For That (TAAIFT)
69
+
70
+ **Title:** Resend MCP Server
71
+
72
+ **Category:** Developer Tools → AI Development → MCP Servers
73
+
74
+ **Description:**
75
+ MCP server that connects AI assistants to Resend's transactional email API. Send emails, manage sending domains, mint API keys, and manage audience contacts by talking to your AI. Requires a Resend API key (free tier supported).
76
+
77
+ **Price:** Free (open source, MIT)
78
+
79
+ ---
80
+
81
+ ## Listing 5: Awesome MCP Servers (GitHub)
82
+
83
+ **PR Description:**
84
+ Add Resend MCP Server — send emails, manage domains & API keys for AI assistants. 10 tools: send/list/get email, create/list/verify domain, create/list API key, list/create contact. Resend API, TypeScript, MIT license.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # Resend MCP Server
2
+
3
+ > An MCP server for [Resend](https://resend.com) — connect any MCP-compatible client to the Resend transactional email API.
4
+
5
+ [![MCP Compatible](https://img.shields.io/badge/MCP-compatible-blueviolet)](https://modelcontextprotocol.io)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
8
+
9
+ ## What is this?
10
+
11
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that gives AI assistants and agents access to [Resend](https://resend.com)'s email API — send transactional emails, manage sending domains, create API keys, and manage audience contacts — through natural language.
12
+
13
+ Use it with **Claude Desktop**, **Cursor**, **Windsurf**, **Cline**, **Continue**, or any MCP-compatible client to send emails, manage infrastructure, and build automation around email.
14
+
15
+ ## Why use this?
16
+
17
+ - **10 built-in tools** — covers sending, domains, API keys, and audiences
18
+ - **Send emails by talking** — "send a welcome email to jane@example.com" just works
19
+ - **Manage your infrastructure** — create/verify domains, mint API keys, manage contacts
20
+ - **Rate-limited automatically** — respects Resend's 10 req/s free tier, retries on 429
21
+ - **Works with every MCP client** — Claude Desktop, Cursor, Windsurf, Cline, Continue, and more
22
+
23
+ ## Tools
24
+
25
+ | Tool | Description |
26
+ |------|-------------|
27
+ | `send_email` | Send a transactional email (HTML or plain text, with Cc/Bcc/Reply-To) |
28
+ | `list_emails` | List recent sent emails with their delivery status |
29
+ | `get_email` | Get details for a specific email by ID |
30
+ | `create_domain` | Add a new sending domain |
31
+ | `list_domains` | List all sending domains in your account |
32
+ | `verify_domain` | Trigger DNS verification for a domain |
33
+ | `create_api_key` | Create a new API key (full, sending, or domain-scoped) |
34
+ | `list_api_keys` | List all API keys (tokens are hidden) |
35
+ | `list_contacts` | List contacts in an audience |
36
+ | `create_contact` | Add a new contact to an audience |
37
+
38
+ ## Quick Start
39
+
40
+ ### 1. Get a Resend API key
41
+
42
+ Sign up at [resend.com](https://resend.com) and grab an API key from [resend.com/api-keys](https://resend.com/api-keys).
43
+
44
+ ### 2. Install
45
+
46
+ ```bash
47
+ npm install -g resend-mcp-server
48
+ ```
49
+
50
+ Or run directly with npx:
51
+
52
+ ```bash
53
+ npx -y resend-mcp-server
54
+ ```
55
+
56
+ ### 3. Configure your MCP client
57
+
58
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "resend": {
64
+ "command": "npx",
65
+ "args": ["-y", "resend-mcp-server"],
66
+ "env": {
67
+ "RESEND_API_KEY": "re_xxxxxxxxxxxx"
68
+ }
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Or with global install:
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "resend": {
80
+ "command": "resend-mcp-server",
81
+ "env": {
82
+ "RESEND_API_KEY": "re_xxxxxxxxxxxx"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ ### 4. Use it
90
+
91
+ Ask your AI assistant things like:
92
+
93
+ - "Send an email to jane@example.com thanking her for signing up"
94
+ - "List the last 5 emails I sent"
95
+ - "What happened to email ID `abc-123`?"
96
+ - "Create a new sending domain for `mail.acme.com`"
97
+ - "Verify domain `domain-xyz`"
98
+ - "List all my sending domains"
99
+ - "Mint a new API key called 'production-server'"
100
+ - "List all my API keys"
101
+ - "Add jane@example.com to audience `audience-1`"
102
+ - "Show me the contacts in audience `audience-1`"
103
+
104
+ ## Example Output
105
+
106
+ ### `send_email`
107
+
108
+ ```
109
+ ✅ Email sent
110
+
111
+ - ID: `a1b2c3d4-...`
112
+ ```
113
+
114
+ ### `list_emails`
115
+
116
+ ```
117
+ 📧 Recent Emails (3):
118
+
119
+ 1. Welcome to Acme!
120
+ From: Acme <hello@acme.com> → To: jane@example.com
121
+ ID: `a1b2c3d4-...` | Last Event: delivered | Created: 2026-01-15T10:30:00Z
122
+
123
+ 2. Your receipt
124
+ From: Acme <billing@acme.com> → To: bob@example.com
125
+ ID: `e5f6g7h8-...` | Last Event: opened | Created: 2026-01-15T09:15:00Z
126
+ ```
127
+
128
+ ### `create_domain`
129
+
130
+ ```
131
+ ✅ Domain created
132
+
133
+ mail.acme.com
134
+ - ID: `domain-xyz-...`
135
+ - Status: pending
136
+ - Region: us-east-1
137
+ - Created: 2026-01-15T10:30:00Z
138
+
139
+ DNS Records:
140
+ - `mail.acme.com` `MX` → `feedback-smtp.us-east-1.amazonses.com`
141
+ - `resend._domainkey.mail.acme.com` `TXT` → `v=DKIM1; k=rsa; p=MIGfMA0GCSq...`
142
+ - `mail.acme.com` `TXT` → `v=spf1 include:amazonses.com ~all`
143
+ ```
144
+
145
+ ## Requirements
146
+
147
+ - Node.js 18+
148
+ - A [Resend](https://resend.com) account and API key (`RESEND_API_KEY`)
149
+
150
+ ## Rate Limits
151
+
152
+ The server automatically rate-limits requests to ~9 calls/second to stay safely under Resend's free-tier limit of 10 req/s. If you hit a 429 anyway, it waits 2s and retries once.
153
+
154
+ ## API Reference
155
+
156
+ All endpoints hit `https://api.resend.com` with a `Bearer` token. See the [Resend docs](https://resend.com/docs) for full details.
157
+
158
+ | Tool | Method | Path |
159
+ |------|--------|------|
160
+ | `send_email` | POST | `/emails` |
161
+ | `list_emails` | GET | `/emails` |
162
+ | `get_email` | GET | `/emails/{id}` |
163
+ | `create_domain` | POST | `/domains` |
164
+ | `list_domains` | GET | `/domains` |
165
+ | `verify_domain` | POST | `/domains/{id}/verify` |
166
+ | `create_api_key` | POST | `/api-keys` |
167
+ | `list_api_keys` | GET | `/api-keys` |
168
+ | `list_contacts` | GET | `/audiences/{id}/contacts` |
169
+ | `create_contact` | POST | `/audiences/{id}/contacts` |
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ git clone https://github.com/nova/resend-mcp-server.git
175
+ cd resend-mcp-server
176
+ npm install
177
+ npm run build
178
+ RESEND_API_KEY=re_xxxx npm start
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Resend MCP Server
4
+ *
5
+ * Connect AI assistants to Resend's transactional email API.
6
+ * Send emails, manage domains, create API keys, and manage contacts
7
+ * through the Model Context Protocol.
8
+ *
9
+ * Works with Claude Desktop, Cursor, Windsurf, Cline, and any MCP client.
10
+ */
11
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Resend MCP Server
4
+ *
5
+ * Connect AI assistants to Resend's transactional email API.
6
+ * Send emails, manage domains, create API keys, and manage contacts
7
+ * through the Model Context Protocol.
8
+ *
9
+ * Works with Claude Desktop, Cursor, Windsurf, Cline, and any MCP client.
10
+ */
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { z } from "zod";
14
+ const RESEND_BASE = "https://api.resend.com";
15
+ // Rate limiter: Resend free tier = 10 req/s. We use 110ms (~9 req/s) to stay safely under.
16
+ let lastCall = 0;
17
+ const MIN_INTERVAL = 110; // ~9 req/s, safe for free tier (10 req/s)
18
+ async function rateLimitedFetch(path, init = {}) {
19
+ const apiKey = process.env.RESEND_API_KEY;
20
+ if (!apiKey) {
21
+ throw new Error("RESEND_API_KEY environment variable is not set. Get an API key at https://resend.com/api-keys");
22
+ }
23
+ const now = Date.now();
24
+ const wait = MIN_INTERVAL - (now - lastCall);
25
+ if (wait > 0) {
26
+ await new Promise((r) => setTimeout(r, wait));
27
+ }
28
+ lastCall = Date.now();
29
+ const res = await fetch(`${RESEND_BASE}${path}`, {
30
+ ...init,
31
+ headers: {
32
+ Authorization: `Bearer ${apiKey}`,
33
+ "Content-Type": "application/json",
34
+ Accept: "application/json",
35
+ ...(init.headers || {}),
36
+ },
37
+ });
38
+ if (res.status === 429) {
39
+ // Rate limited — wait and retry once
40
+ await new Promise((r) => setTimeout(r, 2000));
41
+ const retry = await fetch(`${RESEND_BASE}${path}`, {
42
+ ...init,
43
+ headers: {
44
+ Authorization: `Bearer ${apiKey}`,
45
+ "Content-Type": "application/json",
46
+ Accept: "application/json",
47
+ ...(init.headers || {}),
48
+ },
49
+ });
50
+ if (!retry.ok) {
51
+ const text = await retry.text();
52
+ throw new Error(`Resend API error (${retry.status}): ${text}`);
53
+ }
54
+ return retry.json();
55
+ }
56
+ if (!res.ok) {
57
+ const text = await res.text();
58
+ throw new Error(`Resend API error (${res.status}): ${text}`);
59
+ }
60
+ // 204 No Content (e.g. some DELETE/PUT endpoints)
61
+ if (res.status === 204) {
62
+ return null;
63
+ }
64
+ return res.json();
65
+ }
66
+ // ── Formatting helpers ──
67
+ function formatEmail(e) {
68
+ const lines = [];
69
+ lines.push(`**${e.subject || "(no subject)"}**`);
70
+ lines.push(`- **ID:** \`${e.id}\``);
71
+ lines.push(`- **From:** ${e.from}`);
72
+ lines.push(`- **To:** ${Array.isArray(e.to) ? e.to.join(", ") : e.to}`);
73
+ if (e.cc)
74
+ lines.push(`- **Cc:** ${Array.isArray(e.cc) ? e.cc.join(", ") : e.cc}`);
75
+ if (e.bcc)
76
+ lines.push(`- **Bcc:** ${Array.isArray(e.bcc) ? e.bcc.join(", ") : e.bcc}`);
77
+ if (e.reply_to)
78
+ lines.push(`- **Reply-To:** ${Array.isArray(e.reply_to) ? e.reply_to.join(", ") : e.reply_to}`);
79
+ lines.push(`- **Created:** ${e.created_at || "N/A"}`);
80
+ if (e.last_event)
81
+ lines.push(`- **Last Event:** ${e.last_event}`);
82
+ if (e.status)
83
+ lines.push(`- **Status:** ${e.status}`);
84
+ if (e.scheduled_at)
85
+ lines.push(`- **Scheduled:** ${e.scheduled_at}`);
86
+ return lines.join("\n");
87
+ }
88
+ function formatDomain(d) {
89
+ const lines = [];
90
+ lines.push(`**${d.name}**`);
91
+ lines.push(`- **ID:** \`${d.id}\``);
92
+ lines.push(`- **Status:** ${d.status || "N/A"}`);
93
+ lines.push(`- **Region:** ${d.region || "N/A"}`);
94
+ lines.push(`- **Created:** ${d.created_at || "N/A"}`);
95
+ if (d.records && d.records.length) {
96
+ lines.push("");
97
+ lines.push("**DNS Records:**");
98
+ for (const r of d.records) {
99
+ lines.push(`- \`${r.record}\` \`${r.type}\` → \`${r.value}\`${r.ttl ? ` (TTL ${r.ttl})` : ""}`);
100
+ }
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+ function formatApiKey(k) {
105
+ const lines = [];
106
+ lines.push(`**${k.name}**`);
107
+ lines.push(`- **ID:** \`${k.id}\``);
108
+ lines.push(`- **Token:** \`${k.token || "(hidden — only shown on create)"}\``);
109
+ lines.push(`- **Created:** ${k.created_at || "N/A"}`);
110
+ return lines.join("\n");
111
+ }
112
+ function formatContact(c) {
113
+ const lines = [];
114
+ lines.push(`**${c.first_name || ""}${c.last_name ? " " + c.last_name : ""}** <${c.email}>`);
115
+ lines.push(`- **ID:** \`${c.id}\``);
116
+ if (c.unsubscribed)
117
+ lines.push(`- **Status:** Unsubscribed`);
118
+ else
119
+ lines.push(`- **Status:** Subscribed`);
120
+ lines.push(`- **Created:** ${c.created_at || "N/A"}`);
121
+ return lines.join("\n");
122
+ }
123
+ // Create server
124
+ const server = new McpServer({
125
+ name: "resend",
126
+ version: "1.0.0",
127
+ });
128
+ // ── Tool: send_email ──
129
+ server.tool("send_email", "Send a transactional email via Resend", {
130
+ from: z.string().describe("Sender address (e.g. 'Acme <noreply@acme.com>')"),
131
+ to: z.union([z.string(), z.array(z.string())]).describe("Recipient address(es)"),
132
+ subject: z.string().describe("Email subject line"),
133
+ html: z.string().optional().describe("HTML body"),
134
+ text: z.string().optional().describe("Plain text body"),
135
+ cc: z.union([z.string(), z.array(z.string())]).optional().describe("Cc address(es)"),
136
+ bcc: z.union([z.string(), z.array(z.string())]).optional().describe("Bcc address(es)"),
137
+ reply_to: z.union([z.string(), z.array(z.string())]).optional().describe("Reply-To address(es)"),
138
+ scheduled_at: z.string().optional().describe("ISO 8601 datetime to schedule the email"),
139
+ headers: z.record(z.string()).optional().describe("Custom headers as key/value pairs"),
140
+ }, async ({ from, to, subject, html, text, cc, bcc, reply_to, scheduled_at, headers }) => {
141
+ try {
142
+ if (!html && !text) {
143
+ return {
144
+ content: [
145
+ { type: "text", text: "Error: Provide either `html` or `text` body." },
146
+ ],
147
+ };
148
+ }
149
+ const body = { from, to, subject };
150
+ if (html)
151
+ body.html = html;
152
+ if (text)
153
+ body.text = text;
154
+ if (cc)
155
+ body.cc = cc;
156
+ if (bcc)
157
+ body.bcc = bcc;
158
+ if (reply_to)
159
+ body.reply_to = reply_to;
160
+ if (scheduled_at)
161
+ body.scheduled_at = scheduled_at;
162
+ if (headers)
163
+ body.headers = headers;
164
+ const data = await rateLimitedFetch("/emails", {
165
+ method: "POST",
166
+ body: JSON.stringify(body),
167
+ });
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text",
172
+ text: `✅ **Email sent**\n\n- **ID:** \`${data.id}\``,
173
+ },
174
+ ],
175
+ };
176
+ }
177
+ catch (e) {
178
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
179
+ }
180
+ });
181
+ // ── Tool: list_emails ──
182
+ server.tool("list_emails", "List recent emails sent via Resend", {
183
+ limit: z.number().optional().default(20).describe("Max emails to return (default 20)"),
184
+ }, async ({ limit }) => {
185
+ try {
186
+ const data = await rateLimitedFetch(`/emails?limit=${Math.min(limit, 100)}`);
187
+ const emails = data.data || [];
188
+ if (emails.length === 0) {
189
+ return { content: [{ type: "text", text: "No emails found." }] };
190
+ }
191
+ const lines = emails.map((e, i) => {
192
+ const to = Array.isArray(e.to) ? e.to.join(", ") : e.to;
193
+ return `**${i + 1}. ${e.subject || "(no subject)"}**\n From: ${e.from} → To: ${to}\n ID: \`${e.id}\` | Last Event: ${e.last_event || "N/A"} | Created: ${e.created_at}`;
194
+ });
195
+ return {
196
+ content: [
197
+ { type: "text", text: `**📧 Recent Emails (${emails.length}):**\n\n${lines.join("\n\n")}` },
198
+ ],
199
+ };
200
+ }
201
+ catch (e) {
202
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
203
+ }
204
+ });
205
+ // ── Tool: get_email ──
206
+ server.tool("get_email", "Get details about a specific email by ID", {
207
+ email_id: z.string().describe("Email ID (from send_email or list_emails)"),
208
+ }, async ({ email_id }) => {
209
+ try {
210
+ const data = await rateLimitedFetch(`/emails/${email_id}`);
211
+ return { content: [{ type: "text", text: formatEmail(data) }] };
212
+ }
213
+ catch (e) {
214
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
215
+ }
216
+ });
217
+ // ── Tool: create_domain ──
218
+ server.tool("create_domain", "Create a new sending domain in Resend", {
219
+ name: z.string().describe("Domain name (e.g. 'mail.acme.com')"),
220
+ region: z.enum(["us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"]).optional().describe("AWS region (default: us-east-1)"),
221
+ }, async ({ name, region }) => {
222
+ try {
223
+ const body = { name };
224
+ if (region)
225
+ body.region = region;
226
+ const data = await rateLimitedFetch("/domains", {
227
+ method: "POST",
228
+ body: JSON.stringify(body),
229
+ });
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: `✅ **Domain created**\n\n${formatDomain(data)}`,
235
+ },
236
+ ],
237
+ };
238
+ }
239
+ catch (e) {
240
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
241
+ }
242
+ });
243
+ // ── Tool: list_domains ──
244
+ server.tool("list_domains", "List all sending domains in your Resend account", {
245
+ limit: z.number().optional().default(20).describe("Max domains to return (default 20)"),
246
+ }, async ({ limit }) => {
247
+ try {
248
+ const data = await rateLimitedFetch(`/domains?limit=${Math.min(limit, 100)}`);
249
+ const domains = data.data || [];
250
+ if (domains.length === 0) {
251
+ return { content: [{ type: "text", text: "No domains found." }] };
252
+ }
253
+ const lines = domains.map((d, i) => `**${i + 1}. ${d.name}** — Status: ${d.status || "N/A"} | Region: ${d.region || "N/A"} | ID: \`${d.id}\``);
254
+ return {
255
+ content: [
256
+ { type: "text", text: `**🌐 Domains (${domains.length}):**\n\n${lines.join("\n")}` },
257
+ ],
258
+ };
259
+ }
260
+ catch (e) {
261
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
262
+ }
263
+ });
264
+ // ── Tool: verify_domain ──
265
+ server.tool("verify_domain", "Trigger verification of a domain's DNS records", {
266
+ domain_id: z.string().describe("Domain ID (from create_domain or list_domains)"),
267
+ }, async ({ domain_id }) => {
268
+ try {
269
+ const data = await rateLimitedFetch(`/domains/${domain_id}/verify`, {
270
+ method: "POST",
271
+ });
272
+ return {
273
+ content: [
274
+ {
275
+ type: "text",
276
+ text: `✅ **Verification triggered**\n\n${formatDomain(data)}`,
277
+ },
278
+ ],
279
+ };
280
+ }
281
+ catch (e) {
282
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
283
+ }
284
+ });
285
+ // ── Tool: create_api_key ──
286
+ server.tool("create_api_key", "Create a new Resend API key (token is only returned once)", {
287
+ name: z.string().describe("Friendly name for the key (e.g. 'production-server')"),
288
+ permission: z.enum(["full_access", "sending_access", "domain_read"]).optional().default("full_access").describe("Permission scope (default: full_access)"),
289
+ domain_id: z.string().optional().describe("Restrict key to a specific domain (for sending_access)"),
290
+ }, async ({ name, permission, domain_id }) => {
291
+ try {
292
+ const body = { name };
293
+ if (permission)
294
+ body.permission = permission;
295
+ if (domain_id)
296
+ body.domain_id = domain_id;
297
+ const data = await rateLimitedFetch("/api-keys", {
298
+ method: "POST",
299
+ body: JSON.stringify(body),
300
+ });
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: `✅ **API key created**\n\n${formatApiKey(data)}\n\n⚠️ **Save the token now — it will not be shown again.**`,
306
+ },
307
+ ],
308
+ };
309
+ }
310
+ catch (e) {
311
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
312
+ }
313
+ });
314
+ // ── Tool: list_api_keys ──
315
+ server.tool("list_api_keys", "List all API keys in your Resend account (tokens are hidden)", {}, async () => {
316
+ try {
317
+ const data = await rateLimitedFetch("/api-keys");
318
+ const keys = data.data || [];
319
+ if (keys.length === 0) {
320
+ return { content: [{ type: "text", text: "No API keys found." }] };
321
+ }
322
+ const lines = keys.map((k, i) => `**${i + 1}. ${k.name}** — ID: \`${k.id}\` | Created: ${k.created_at || "N/A"}`);
323
+ return {
324
+ content: [
325
+ { type: "text", text: `**🔑 API Keys (${keys.length}):**\n\n${lines.join("\n")}` },
326
+ ],
327
+ };
328
+ }
329
+ catch (e) {
330
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
331
+ }
332
+ });
333
+ // ── Tool: list_contacts ──
334
+ server.tool("list_contacts", "List contacts in a Resend audience", {
335
+ audience_id: z.string().describe("Audience ID (e.g. from your Resend dashboard)"),
336
+ limit: z.number().optional().default(20).describe("Max contacts to return (default 20)"),
337
+ }, async ({ audience_id, limit }) => {
338
+ try {
339
+ const data = await rateLimitedFetch(`/audiences/${audience_id}/contacts?limit=${Math.min(limit, 100)}`);
340
+ const contacts = data.data || [];
341
+ if (contacts.length === 0) {
342
+ return { content: [{ type: "text", text: "No contacts found." }] };
343
+ }
344
+ const lines = contacts.map((c, i) => {
345
+ const name = `${c.first_name || ""}${c.last_name ? " " + c.last_name : ""}`.trim() || "(no name)";
346
+ return `**${i + 1}. ${name}** <${c.email}> — ${c.unsubscribed ? "Unsubscribed" : "Subscribed"} | ID: \`${c.id}\``;
347
+ });
348
+ return {
349
+ content: [
350
+ { type: "text", text: `**👥 Contacts (${contacts.length}):**\n\n${lines.join("\n")}` },
351
+ ],
352
+ };
353
+ }
354
+ catch (e) {
355
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
356
+ }
357
+ });
358
+ // ── Tool: create_contact ──
359
+ server.tool("create_contact", "Add a new contact to a Resend audience", {
360
+ audience_id: z.string().describe("Audience ID"),
361
+ email: z.string().email().describe("Contact email address"),
362
+ first_name: z.string().optional().describe("First name"),
363
+ last_name: z.string().optional().describe("Last name"),
364
+ unsubscribed: z.boolean().optional().default(false).describe("Whether the contact is unsubscribed"),
365
+ }, async ({ audience_id, email, first_name, last_name, unsubscribed }) => {
366
+ try {
367
+ const body = { email };
368
+ if (first_name)
369
+ body.first_name = first_name;
370
+ if (last_name)
371
+ body.last_name = last_name;
372
+ if (unsubscribed !== undefined)
373
+ body.unsubscribed = unsubscribed;
374
+ const data = await rateLimitedFetch(`/audiences/${audience_id}/contacts`, {
375
+ method: "POST",
376
+ body: JSON.stringify(body),
377
+ });
378
+ return {
379
+ content: [
380
+ {
381
+ type: "text",
382
+ text: `✅ **Contact created**\n\n${formatContact(data)}`,
383
+ },
384
+ ],
385
+ };
386
+ }
387
+ catch (e) {
388
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
389
+ }
390
+ });
391
+ // Start server
392
+ async function main() {
393
+ const transport = new StdioServerTransport();
394
+ await server.connect(transport);
395
+ }
396
+ main().catch((err) => {
397
+ console.error("Fatal error:", err);
398
+ process.exit(1);
399
+ });
400
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@supernova123/resend-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Resend \u2014 transactional email API for AI assistants",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "resend-mcp-server": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "tsc && node dist/index.js"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "resend",
19
+ "email",
20
+ "transactional",
21
+ "api",
22
+ "ai",
23
+ "claude",
24
+ "cursor"
25
+ ],
26
+ "author": "Nova",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.0.0",
30
+ "zod": "^3.23.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.5.0",
34
+ "@types/node": "^20.0.0"
35
+ },
36
+ "mcpName": "io.github.friendlygeorge/resend-mcp-server",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/friendlygeorge/resend-mcp-server.git"
40
+ },
41
+ "homepage": "https://github.com/friendlygeorge/resend-mcp-server#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/friendlygeorge/resend-mcp-server/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Resend MCP Server
4
+ *
5
+ * Connect AI assistants to Resend's transactional email API.
6
+ * Send emails, manage domains, create API keys, and manage contacts
7
+ * through the Model Context Protocol.
8
+ *
9
+ * Works with Claude Desktop, Cursor, Windsurf, Cline, and any MCP client.
10
+ */
11
+
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+
16
+ const RESEND_BASE = "https://api.resend.com";
17
+
18
+ // Rate limiter: Resend free tier = 10 req/s. We use 110ms (~9 req/s) to stay safely under.
19
+ let lastCall = 0;
20
+ const MIN_INTERVAL = 110; // ~9 req/s, safe for free tier (10 req/s)
21
+
22
+ async function rateLimitedFetch(
23
+ path: string,
24
+ init: RequestInit = {}
25
+ ): Promise<any> {
26
+ const apiKey = process.env.RESEND_API_KEY;
27
+ if (!apiKey) {
28
+ throw new Error(
29
+ "RESEND_API_KEY environment variable is not set. Get an API key at https://resend.com/api-keys"
30
+ );
31
+ }
32
+
33
+ const now = Date.now();
34
+ const wait = MIN_INTERVAL - (now - lastCall);
35
+ if (wait > 0) {
36
+ await new Promise((r) => setTimeout(r, wait));
37
+ }
38
+ lastCall = Date.now();
39
+
40
+ const res = await fetch(`${RESEND_BASE}${path}`, {
41
+ ...init,
42
+ headers: {
43
+ Authorization: `Bearer ${apiKey}`,
44
+ "Content-Type": "application/json",
45
+ Accept: "application/json",
46
+ ...(init.headers || {}),
47
+ },
48
+ });
49
+
50
+ if (res.status === 429) {
51
+ // Rate limited — wait and retry once
52
+ await new Promise((r) => setTimeout(r, 2000));
53
+ const retry = await fetch(`${RESEND_BASE}${path}`, {
54
+ ...init,
55
+ headers: {
56
+ Authorization: `Bearer ${apiKey}`,
57
+ "Content-Type": "application/json",
58
+ Accept: "application/json",
59
+ ...(init.headers || {}),
60
+ },
61
+ });
62
+ if (!retry.ok) {
63
+ const text = await retry.text();
64
+ throw new Error(`Resend API error (${retry.status}): ${text}`);
65
+ }
66
+ return retry.json();
67
+ }
68
+
69
+ if (!res.ok) {
70
+ const text = await res.text();
71
+ throw new Error(`Resend API error (${res.status}): ${text}`);
72
+ }
73
+
74
+ // 204 No Content (e.g. some DELETE/PUT endpoints)
75
+ if (res.status === 204) {
76
+ return null;
77
+ }
78
+ return res.json();
79
+ }
80
+
81
+ // ── Formatting helpers ──
82
+
83
+ function formatEmail(e: any): string {
84
+ const lines: string[] = [];
85
+ lines.push(`**${e.subject || "(no subject)"}**`);
86
+ lines.push(`- **ID:** \`${e.id}\``);
87
+ lines.push(`- **From:** ${e.from}`);
88
+ lines.push(`- **To:** ${Array.isArray(e.to) ? e.to.join(", ") : e.to}`);
89
+ if (e.cc) lines.push(`- **Cc:** ${Array.isArray(e.cc) ? e.cc.join(", ") : e.cc}`);
90
+ if (e.bcc) lines.push(`- **Bcc:** ${Array.isArray(e.bcc) ? e.bcc.join(", ") : e.bcc}`);
91
+ if (e.reply_to) lines.push(`- **Reply-To:** ${Array.isArray(e.reply_to) ? e.reply_to.join(", ") : e.reply_to}`);
92
+ lines.push(`- **Created:** ${e.created_at || "N/A"}`);
93
+ if (e.last_event) lines.push(`- **Last Event:** ${e.last_event}`);
94
+ if (e.status) lines.push(`- **Status:** ${e.status}`);
95
+ if (e.scheduled_at) lines.push(`- **Scheduled:** ${e.scheduled_at}`);
96
+ return lines.join("\n");
97
+ }
98
+
99
+ function formatDomain(d: any): string {
100
+ const lines: string[] = [];
101
+ lines.push(`**${d.name}**`);
102
+ lines.push(`- **ID:** \`${d.id}\``);
103
+ lines.push(`- **Status:** ${d.status || "N/A"}`);
104
+ lines.push(`- **Region:** ${d.region || "N/A"}`);
105
+ lines.push(`- **Created:** ${d.created_at || "N/A"}`);
106
+ if (d.records && d.records.length) {
107
+ lines.push("");
108
+ lines.push("**DNS Records:**");
109
+ for (const r of d.records) {
110
+ lines.push(`- \`${r.record}\` \`${r.type}\` → \`${r.value}\`${r.ttl ? ` (TTL ${r.ttl})` : ""}`);
111
+ }
112
+ }
113
+ return lines.join("\n");
114
+ }
115
+
116
+ function formatApiKey(k: any): string {
117
+ const lines: string[] = [];
118
+ lines.push(`**${k.name}**`);
119
+ lines.push(`- **ID:** \`${k.id}\``);
120
+ lines.push(`- **Token:** \`${k.token || "(hidden — only shown on create)"}\``);
121
+ lines.push(`- **Created:** ${k.created_at || "N/A"}`);
122
+ return lines.join("\n");
123
+ }
124
+
125
+ function formatContact(c: any): string {
126
+ const lines: string[] = [];
127
+ lines.push(`**${c.first_name || ""}${c.last_name ? " " + c.last_name : ""}** <${c.email}>`);
128
+ lines.push(`- **ID:** \`${c.id}\``);
129
+ if (c.unsubscribed) lines.push(`- **Status:** Unsubscribed`);
130
+ else lines.push(`- **Status:** Subscribed`);
131
+ lines.push(`- **Created:** ${c.created_at || "N/A"}`);
132
+ return lines.join("\n");
133
+ }
134
+
135
+ // Create server
136
+ const server = new McpServer({
137
+ name: "resend",
138
+ version: "1.0.0",
139
+ });
140
+
141
+ // ── Tool: send_email ──
142
+ server.tool(
143
+ "send_email",
144
+ "Send a transactional email via Resend",
145
+ {
146
+ from: z.string().describe("Sender address (e.g. 'Acme <noreply@acme.com>')"),
147
+ to: z.union([z.string(), z.array(z.string())]).describe("Recipient address(es)"),
148
+ subject: z.string().describe("Email subject line"),
149
+ html: z.string().optional().describe("HTML body"),
150
+ text: z.string().optional().describe("Plain text body"),
151
+ cc: z.union([z.string(), z.array(z.string())]).optional().describe("Cc address(es)"),
152
+ bcc: z.union([z.string(), z.array(z.string())]).optional().describe("Bcc address(es)"),
153
+ reply_to: z.union([z.string(), z.array(z.string())]).optional().describe("Reply-To address(es)"),
154
+ scheduled_at: z.string().optional().describe("ISO 8601 datetime to schedule the email"),
155
+ headers: z.record(z.string()).optional().describe("Custom headers as key/value pairs"),
156
+ },
157
+ async ({ from, to, subject, html, text, cc, bcc, reply_to, scheduled_at, headers }) => {
158
+ try {
159
+ if (!html && !text) {
160
+ return {
161
+ content: [
162
+ { type: "text" as const, text: "Error: Provide either `html` or `text` body." },
163
+ ],
164
+ };
165
+ }
166
+ const body: any = { from, to, subject };
167
+ if (html) body.html = html;
168
+ if (text) body.text = text;
169
+ if (cc) body.cc = cc;
170
+ if (bcc) body.bcc = bcc;
171
+ if (reply_to) body.reply_to = reply_to;
172
+ if (scheduled_at) body.scheduled_at = scheduled_at;
173
+ if (headers) body.headers = headers;
174
+
175
+ const data = await rateLimitedFetch("/emails", {
176
+ method: "POST",
177
+ body: JSON.stringify(body),
178
+ });
179
+ return {
180
+ content: [
181
+ {
182
+ type: "text" as const,
183
+ text: `✅ **Email sent**\n\n- **ID:** \`${data.id}\``,
184
+ },
185
+ ],
186
+ };
187
+ } catch (e: any) {
188
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
189
+ }
190
+ }
191
+ );
192
+
193
+ // ── Tool: list_emails ──
194
+ server.tool(
195
+ "list_emails",
196
+ "List recent emails sent via Resend",
197
+ {
198
+ limit: z.number().optional().default(20).describe("Max emails to return (default 20)"),
199
+ },
200
+ async ({ limit }) => {
201
+ try {
202
+ const data = await rateLimitedFetch(`/emails?limit=${Math.min(limit, 100)}`);
203
+ const emails = data.data || [];
204
+ if (emails.length === 0) {
205
+ return { content: [{ type: "text" as const, text: "No emails found." }] };
206
+ }
207
+ const lines = emails.map((e: any, i: number) => {
208
+ const to = Array.isArray(e.to) ? e.to.join(", ") : e.to;
209
+ return `**${i + 1}. ${e.subject || "(no subject)"}**\n From: ${e.from} → To: ${to}\n ID: \`${e.id}\` | Last Event: ${e.last_event || "N/A"} | Created: ${e.created_at}`;
210
+ });
211
+ return {
212
+ content: [
213
+ { type: "text" as const, text: `**📧 Recent Emails (${emails.length}):**\n\n${lines.join("\n\n")}` },
214
+ ],
215
+ };
216
+ } catch (e: any) {
217
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
218
+ }
219
+ }
220
+ );
221
+
222
+ // ── Tool: get_email ──
223
+ server.tool(
224
+ "get_email",
225
+ "Get details about a specific email by ID",
226
+ {
227
+ email_id: z.string().describe("Email ID (from send_email or list_emails)"),
228
+ },
229
+ async ({ email_id }) => {
230
+ try {
231
+ const data = await rateLimitedFetch(`/emails/${email_id}`);
232
+ return { content: [{ type: "text" as const, text: formatEmail(data) }] };
233
+ } catch (e: any) {
234
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
235
+ }
236
+ }
237
+ );
238
+
239
+ // ── Tool: create_domain ──
240
+ server.tool(
241
+ "create_domain",
242
+ "Create a new sending domain in Resend",
243
+ {
244
+ name: z.string().describe("Domain name (e.g. 'mail.acme.com')"),
245
+ region: z.enum(["us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"]).optional().describe("AWS region (default: us-east-1)"),
246
+ },
247
+ async ({ name, region }) => {
248
+ try {
249
+ const body: any = { name };
250
+ if (region) body.region = region;
251
+ const data = await rateLimitedFetch("/domains", {
252
+ method: "POST",
253
+ body: JSON.stringify(body),
254
+ });
255
+ return {
256
+ content: [
257
+ {
258
+ type: "text" as const,
259
+ text: `✅ **Domain created**\n\n${formatDomain(data)}`,
260
+ },
261
+ ],
262
+ };
263
+ } catch (e: any) {
264
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
265
+ }
266
+ }
267
+ );
268
+
269
+ // ── Tool: list_domains ──
270
+ server.tool(
271
+ "list_domains",
272
+ "List all sending domains in your Resend account",
273
+ {
274
+ limit: z.number().optional().default(20).describe("Max domains to return (default 20)"),
275
+ },
276
+ async ({ limit }) => {
277
+ try {
278
+ const data = await rateLimitedFetch(`/domains?limit=${Math.min(limit, 100)}`);
279
+ const domains = data.data || [];
280
+ if (domains.length === 0) {
281
+ return { content: [{ type: "text" as const, text: "No domains found." }] };
282
+ }
283
+ const lines = domains.map((d: any, i: number) =>
284
+ `**${i + 1}. ${d.name}** — Status: ${d.status || "N/A"} | Region: ${d.region || "N/A"} | ID: \`${d.id}\``
285
+ );
286
+ return {
287
+ content: [
288
+ { type: "text" as const, text: `**🌐 Domains (${domains.length}):**\n\n${lines.join("\n")}` },
289
+ ],
290
+ };
291
+ } catch (e: any) {
292
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
293
+ }
294
+ }
295
+ );
296
+
297
+ // ── Tool: verify_domain ──
298
+ server.tool(
299
+ "verify_domain",
300
+ "Trigger verification of a domain's DNS records",
301
+ {
302
+ domain_id: z.string().describe("Domain ID (from create_domain or list_domains)"),
303
+ },
304
+ async ({ domain_id }) => {
305
+ try {
306
+ const data = await rateLimitedFetch(`/domains/${domain_id}/verify`, {
307
+ method: "POST",
308
+ });
309
+ return {
310
+ content: [
311
+ {
312
+ type: "text" as const,
313
+ text: `✅ **Verification triggered**\n\n${formatDomain(data)}`,
314
+ },
315
+ ],
316
+ };
317
+ } catch (e: any) {
318
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
319
+ }
320
+ }
321
+ );
322
+
323
+ // ── Tool: create_api_key ──
324
+ server.tool(
325
+ "create_api_key",
326
+ "Create a new Resend API key (token is only returned once)",
327
+ {
328
+ name: z.string().describe("Friendly name for the key (e.g. 'production-server')"),
329
+ permission: z.enum(["full_access", "sending_access", "domain_read"]).optional().default("full_access").describe("Permission scope (default: full_access)"),
330
+ domain_id: z.string().optional().describe("Restrict key to a specific domain (for sending_access)"),
331
+ },
332
+ async ({ name, permission, domain_id }) => {
333
+ try {
334
+ const body: any = { name };
335
+ if (permission) body.permission = permission;
336
+ if (domain_id) body.domain_id = domain_id;
337
+ const data = await rateLimitedFetch("/api-keys", {
338
+ method: "POST",
339
+ body: JSON.stringify(body),
340
+ });
341
+ return {
342
+ content: [
343
+ {
344
+ type: "text" as const,
345
+ text: `✅ **API key created**\n\n${formatApiKey(data)}\n\n⚠️ **Save the token now — it will not be shown again.**`,
346
+ },
347
+ ],
348
+ };
349
+ } catch (e: any) {
350
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
351
+ }
352
+ }
353
+ );
354
+
355
+ // ── Tool: list_api_keys ──
356
+ server.tool(
357
+ "list_api_keys",
358
+ "List all API keys in your Resend account (tokens are hidden)",
359
+ {},
360
+ async () => {
361
+ try {
362
+ const data = await rateLimitedFetch("/api-keys");
363
+ const keys = data.data || [];
364
+ if (keys.length === 0) {
365
+ return { content: [{ type: "text" as const, text: "No API keys found." }] };
366
+ }
367
+ const lines = keys.map((k: any, i: number) =>
368
+ `**${i + 1}. ${k.name}** — ID: \`${k.id}\` | Created: ${k.created_at || "N/A"}`
369
+ );
370
+ return {
371
+ content: [
372
+ { type: "text" as const, text: `**🔑 API Keys (${keys.length}):**\n\n${lines.join("\n")}` },
373
+ ],
374
+ };
375
+ } catch (e: any) {
376
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
377
+ }
378
+ }
379
+ );
380
+
381
+ // ── Tool: list_contacts ──
382
+ server.tool(
383
+ "list_contacts",
384
+ "List contacts in a Resend audience",
385
+ {
386
+ audience_id: z.string().describe("Audience ID (e.g. from your Resend dashboard)"),
387
+ limit: z.number().optional().default(20).describe("Max contacts to return (default 20)"),
388
+ },
389
+ async ({ audience_id, limit }) => {
390
+ try {
391
+ const data = await rateLimitedFetch(
392
+ `/audiences/${audience_id}/contacts?limit=${Math.min(limit, 100)}`
393
+ );
394
+ const contacts = data.data || [];
395
+ if (contacts.length === 0) {
396
+ return { content: [{ type: "text" as const, text: "No contacts found." }] };
397
+ }
398
+ const lines = contacts.map((c: any, i: number) => {
399
+ const name = `${c.first_name || ""}${c.last_name ? " " + c.last_name : ""}`.trim() || "(no name)";
400
+ return `**${i + 1}. ${name}** <${c.email}> — ${c.unsubscribed ? "Unsubscribed" : "Subscribed"} | ID: \`${c.id}\``;
401
+ });
402
+ return {
403
+ content: [
404
+ { type: "text" as const, text: `**👥 Contacts (${contacts.length}):**\n\n${lines.join("\n")}` },
405
+ ],
406
+ };
407
+ } catch (e: any) {
408
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
409
+ }
410
+ }
411
+ );
412
+
413
+ // ── Tool: create_contact ──
414
+ server.tool(
415
+ "create_contact",
416
+ "Add a new contact to a Resend audience",
417
+ {
418
+ audience_id: z.string().describe("Audience ID"),
419
+ email: z.string().email().describe("Contact email address"),
420
+ first_name: z.string().optional().describe("First name"),
421
+ last_name: z.string().optional().describe("Last name"),
422
+ unsubscribed: z.boolean().optional().default(false).describe("Whether the contact is unsubscribed"),
423
+ },
424
+ async ({ audience_id, email, first_name, last_name, unsubscribed }) => {
425
+ try {
426
+ const body: any = { email };
427
+ if (first_name) body.first_name = first_name;
428
+ if (last_name) body.last_name = last_name;
429
+ if (unsubscribed !== undefined) body.unsubscribed = unsubscribed;
430
+ const data = await rateLimitedFetch(`/audiences/${audience_id}/contacts`, {
431
+ method: "POST",
432
+ body: JSON.stringify(body),
433
+ });
434
+ return {
435
+ content: [
436
+ {
437
+ type: "text" as const,
438
+ text: `✅ **Contact created**\n\n${formatContact(data)}`,
439
+ },
440
+ ],
441
+ };
442
+ } catch (e: any) {
443
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
444
+ }
445
+ }
446
+ );
447
+
448
+ // Start server
449
+ async function main() {
450
+ const transport = new StdioServerTransport();
451
+ await server.connect(transport);
452
+ }
453
+
454
+ main().catch((err) => {
455
+ console.error("Fatal error:", err);
456
+ process.exit(1);
457
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true
14
+ },
15
+ "include": ["src/**/*"]
16
+ }