@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.
- package/.github/FUNDING.yml +7 -0
- package/LICENSE +21 -0
- package/MARKETPLACE-LISTINGS.md +84 -0
- package/README.md +183 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +400 -0
- package/package.json +48 -0
- package/src/index.ts +457 -0
- package/tsconfig.json +16 -0
|
@@ -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
|
+
[](https://modelcontextprotocol.io)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|