From 3a284a148671c7465ca55f64fd8f30bb596ef869 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:00:51 -0700 Subject: [PATCH 1/9] Clean up changelog Everything in the previous "Unreleased" section was actually released in v1.1.0, so I merged its contents with the 1.1.0 section and added a new Unreleased section at the top for future changes. --- CHANGELOG.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 434ea2f..f338134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,39 @@ All notable changes to the MCP Send Email project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + ## [1.1.0] - 2025-07-08 ### Added + - List audiences tool for Resend - Removed React Email dependencies since it's not used in the project - Updated Resend to latest version - Add biome for formatting - -## [Unreleased] - -- Improved instructions in README -- Removed test email address from example email.md - -### Added - CC and BCC support for email recipients - Full request/response logging for improved debugging - New "Features" section in README documentation - Usage examples for CC/BCC in email.md ### Fixed + - Sender email handling with Resend's API - Type definitions for email request object ### Changed + +- Improved instructions in README +- Removed test email address from example email.md - Enhanced console logging for easier troubleshooting - Updated documentation with Resend's email verification requirements ## [1.0.0] - 2025-02-24 + ### Added + - Initial release - Basic email sending functionality - HTML email support - Email scheduling capability -- Reply-to addressing \ No newline at end of file +- Reply-to addressing From fb7d342eed42aa69079c7ee6964a0afd721de6cc Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:04:43 -0700 Subject: [PATCH 2/9] Sync up `package.json` version with changelog Not crucial, since this isn't an npm package, but we may as well. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e0ff29..f9bb11a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "send-email", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "send-email", - "version": "1.0.0", + "version": "1.1.0", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.5.0", diff --git a/package.json b/package.json index f23d902..5b4a606 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "send-email", - "version": "1.0.0", + "version": "1.1.0", "main": "index.js", "keywords": [], "author": "", From f9fe15bffcd6ff6315cd5da1bab2e853d479e268 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sat, 27 Sep 2025 14:43:13 -0700 Subject: [PATCH 3/9] Pull `package.json` version for server version --- index.ts | 3 ++- tsconfig.json | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index fd61ac5..e5ab667 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import minimist from 'minimist'; import { Resend } from 'resend'; import { z } from 'zod'; +import packageJson from './package.json' with { type: 'json' }; // Parse command line arguments const argv = minimist(process.argv.slice(2)); @@ -37,7 +38,7 @@ const resend = new Resend(apiKey); // Create server instance const server = new McpServer({ name: 'email-sending-service', - version: '1.0.0', + version: packageJson.version, }); server.tool( diff --git a/tsconfig.json b/tsconfig.json index 7a220e2..89b4d40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, "jsx": "react-jsx", "outDir": "./build", "rootDir": ".", From b20b153497cd9c9e1678ce727ca5efb1a9b5ce60 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:45:07 -0700 Subject: [PATCH 4/9] Upgrade Resend SDK to latest --- CHANGELOG.md | 4 + package-lock.json | 262 ++-------------------------------------------- package.json | 2 +- 3 files changed, 16 insertions(+), 252 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f338134..3f9c8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Upgrade Resend SDK to v6.1.0 + ## [1.1.0] - 2025-07-08 ### Added diff --git a/package-lock.json b/package-lock.json index f9bb11a..f8a20ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.5.0", "minimist": "^1.2.8", - "resend": "^4.1.2", + "resend": "^6.1.0", "zod": "^3.24.2" }, "devDependencies": { @@ -201,37 +201,6 @@ "node": ">=18" } }, - "node_modules/@react-email/render": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", - "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", - "license": "MIT", - "dependencies": { - "html-to-text": "^9.0.5", - "prettier": "^3.5.3", - "react-promise-suspense": "^0.3.4" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "react": "^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" - } - }, - "node_modules/@selderee/plugin-htmlparser2": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", - "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "selderee": "^0.11.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -267,15 +236,6 @@ "node": ">= 0.6" } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -285,73 +245,6 @@ "node": ">= 0.8" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/eventsource": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", @@ -373,47 +266,6 @@ "node": ">=18.0.0" } }, - "node_modules/fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", - "license": "MIT" - }, - "node_modules/html-to-text": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", - "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", - "license": "MIT", - "dependencies": { - "@selderee/plugin-htmlparser2": "^0.11.0", - "deepmerge": "^4.3.1", - "dom-serializer": "^2.0.0", - "htmlparser2": "^8.0.2", - "selderee": "^0.11.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -448,15 +300,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/leac": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", - "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", - "license": "MIT", - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -466,43 +309,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/parseley": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", - "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", - "license": "MIT", - "dependencies": { - "leac": "^0.6.0", - "peberminta": "^0.9.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/peberminta": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", - "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", - "license": "MIT", - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/raw-body": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", @@ -518,48 +324,21 @@ "node": ">= 0.8" } }, - "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "node_modules/resend": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.1.0.tgz", + "integrity": "sha512-H0cJI2pcLk5/dGwyvZUHu+O7X/q6arvc40EWm+pRPuy+PSWojH5utZtmDBUZ2L0+gVwYZiWs6y2lw6GQA1z1rg==", "license": "MIT", - "peer": true, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.26.0" + "node": ">=18" }, "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-promise-suspense": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", - "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^2.0.1" - } - }, - "node_modules/resend": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-4.6.0.tgz", - "integrity": "sha512-D5T2I82FvEUYFlrHzaDvVtr5ADHdhuoLaXgLFGABKyNtQgPWIuz0Vp2L2Evx779qjK37aF4kcw1yXJDHhA2JnQ==", - "license": "MIT", - "dependencies": { - "@react-email/render": "1.1.2" + "@react-email/render": "^1.1.0" }, - "engines": { - "node": ">=18" + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } } }, "node_modules/safer-buffer": { @@ -568,25 +347,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT", - "peer": true - }, - "node_modules/selderee": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", - "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", - "license": "MIT", - "dependencies": { - "parseley": "^0.12.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index 5b4a606..c97dfa6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.5.0", "minimist": "^1.2.8", - "resend": "^4.1.2", + "resend": "^6.1.0", "zod": "^3.24.2" }, "devDependencies": { From d2e1ffd911ed327fd8d728990c968e2e0a01ebec Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sat, 27 Sep 2025 15:26:02 -0700 Subject: [PATCH 5/9] Split tools into separate files I'm planning on adding several more after this. --- index.ts | 166 +-------------------------------------------- tools/audiences.ts | 30 ++++++++ tools/emails.ts | 162 +++++++++++++++++++++++++++++++++++++++++++ tools/index.ts | 2 + 4 files changed, 197 insertions(+), 163 deletions(-) create mode 100644 tools/audiences.ts create mode 100644 tools/emails.ts create mode 100644 tools/index.ts diff --git a/index.ts b/index.ts index e5ab667..e704403 100644 --- a/index.ts +++ b/index.ts @@ -2,8 +2,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import minimist from 'minimist'; import { Resend } from 'resend'; -import { z } from 'zod'; import packageJson from './package.json' with { type: 'json' }; +import { addAudienceTools, addEmailTools } from './tools/index.js'; // Parse command line arguments const argv = minimist(process.argv.slice(2)); @@ -41,168 +41,8 @@ const server = new McpServer({ version: packageJson.version, }); -server.tool( - 'send-email', - 'Send an email using Resend', - { - to: z.string().email().describe('Recipient email address'), - subject: z.string().describe('Email subject line'), - text: z.string().describe('Plain text email content'), - html: z - .string() - .optional() - .describe( - 'HTML email content. When provided, the plain text argument MUST be provided as well.', - ), - cc: z - .string() - .email() - .array() - .optional() - .describe( - 'Optional array of CC email addresses. You MUST ask the user for this parameter. Under no circumstance provide it yourself', - ), - bcc: z - .string() - .email() - .array() - .optional() - .describe( - 'Optional array of BCC email addresses. You MUST ask the user for this parameter. Under no circumstance provide it yourself', - ), - scheduledAt: z - .string() - .optional() - .describe( - "Optional parameter to schedule the email. This uses natural language. Examples would be 'tomorrow at 10am' or 'in 2 hours' or 'next day at 9am PST' or 'Friday at 3pm ET'.", - ), - // If sender email address is not provided, the tool requires it as an argument - ...(!senderEmailAddress - ? { - from: z - .string() - .email() - .nonempty() - .describe( - 'Sender email address. You MUST ask the user for this parameter. Under no circumstance provide it yourself', - ), - } - : {}), - ...(replierEmailAddresses.length === 0 - ? { - replyTo: z - .string() - .email() - .array() - .optional() - .describe( - 'Optional email addresses for the email readers to reply to. You MUST ask the user for this parameter. Under no circumstance provide it yourself', - ), - } - : {}), - }, - async ({ from, to, subject, text, html, replyTo, scheduledAt, cc, bcc }) => { - const fromEmailAddress = from ?? senderEmailAddress; - const replyToEmailAddresses = replyTo ?? replierEmailAddresses; - - // Type check on from, since "from" is optionally included in the arguments schema - // This should never happen. - if (typeof fromEmailAddress !== 'string') { - throw new Error('from argument must be provided.'); - } - - // Similar type check for "reply-to" email addresses. - if ( - typeof replyToEmailAddresses !== 'string' && - !Array.isArray(replyToEmailAddresses) - ) { - throw new Error('replyTo argument must be provided.'); - } - - console.error(`Debug - Sending email with from: ${fromEmailAddress}`); - - // Explicitly structure the request with all parameters to ensure they're passed correctly - const emailRequest: { - to: string; - subject: string; - text: string; - from: string; - replyTo: string | string[]; - html?: string; - scheduledAt?: string; - cc?: string[]; - bcc?: string[]; - } = { - to, - subject, - text, - from: fromEmailAddress, - replyTo: replyToEmailAddresses, - }; - - // Add optional parameters conditionally - if (html) { - emailRequest.html = html; - } - - if (scheduledAt) { - emailRequest.scheduledAt = scheduledAt; - } - - if (cc) { - emailRequest.cc = cc; - } - - if (bcc) { - emailRequest.bcc = bcc; - } - - console.error(`Email request: ${JSON.stringify(emailRequest)}`); - - const response = await resend.emails.send(emailRequest); - - if (response.error) { - throw new Error( - `Email failed to send: ${JSON.stringify(response.error)}`, - ); - } - - return { - content: [ - { - type: 'text', - text: `Email sent successfully! ${JSON.stringify(response.data)}`, - }, - ], - }; - }, -); - -server.tool( - 'list-audiences', - 'List all audiences from Resend. This tool is useful for getting the audience ID to help the user find the audience they want to use for other tools. If you need an audience ID, you MUST use this tool to get all available audiences and then ask the user to select the audience they want to use.', - {}, - async () => { - console.error('Debug - Listing audiences'); - - const response = await resend.audiences.list(); - - if (response.error) { - throw new Error( - `Failed to list audiences: ${JSON.stringify(response.error)}`, - ); - } - - return { - content: [ - { - type: 'text', - text: `Audiences found: ${JSON.stringify(response.data)}`, - }, - ], - }; - }, -); +addAudienceTools(server, resend); +addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); async function main() { const transport = new StdioServerTransport(); diff --git a/tools/audiences.ts b/tools/audiences.ts new file mode 100644 index 0000000..1214401 --- /dev/null +++ b/tools/audiences.ts @@ -0,0 +1,30 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Resend } from 'resend'; + +export function addAudienceTools(server: McpServer, resend: Resend) { + server.tool( + 'list-audiences', + 'List all audiences from Resend. This tool is useful for getting the audience ID to help the user find the audience they want to use for other tools. If you need an audience ID, you MUST use this tool to get all available audiences and then ask the user to select the audience they want to use.', + {}, + async () => { + console.error('Debug - Listing audiences'); + + const response = await resend.audiences.list(); + + if (response.error) { + throw new Error( + `Failed to list audiences: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { + type: 'text', + text: `Audiences found: ${JSON.stringify(response.data)}`, + }, + ], + }; + }, + ); +} diff --git a/tools/emails.ts b/tools/emails.ts new file mode 100644 index 0000000..aa25a9f --- /dev/null +++ b/tools/emails.ts @@ -0,0 +1,162 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Resend } from 'resend'; +import { z } from 'zod'; + +export function addEmailTools( + server: McpServer, + resend: Resend, + { + senderEmailAddress, + replierEmailAddresses, + }: { + senderEmailAddress?: string; + replierEmailAddresses: string[]; + }, +) { + server.tool( + 'send-email', + 'Send an email using Resend', + { + to: z.string().email().describe('Recipient email address'), + subject: z.string().describe('Email subject line'), + text: z.string().describe('Plain text email content'), + html: z + .string() + .optional() + .describe( + 'HTML email content. When provided, the plain text argument MUST be provided as well.', + ), + cc: z + .string() + .email() + .array() + .optional() + .describe( + 'Optional array of CC email addresses. You MUST ask the user for this parameter. Under no circumstance provide it yourself', + ), + bcc: z + .string() + .email() + .array() + .optional() + .describe( + 'Optional array of BCC email addresses. You MUST ask the user for this parameter. Under no circumstance provide it yourself', + ), + scheduledAt: z + .string() + .optional() + .describe( + "Optional parameter to schedule the email. This uses natural language. Examples would be 'tomorrow at 10am' or 'in 2 hours' or 'next day at 9am PST' or 'Friday at 3pm ET'.", + ), + // If sender email address is not provided, the tool requires it as an argument + ...(!senderEmailAddress + ? { + from: z + .string() + .email() + .nonempty() + .describe( + 'Sender email address. You MUST ask the user for this parameter. Under no circumstance provide it yourself', + ), + } + : {}), + ...(replierEmailAddresses.length === 0 + ? { + replyTo: z + .string() + .email() + .array() + .optional() + .describe( + 'Optional email addresses for the email readers to reply to. You MUST ask the user for this parameter. Under no circumstance provide it yourself', + ), + } + : {}), + }, + async ({ + from, + to, + subject, + text, + html, + replyTo, + scheduledAt, + cc, + bcc, + }) => { + const fromEmailAddress = from ?? senderEmailAddress; + const replyToEmailAddresses = replyTo ?? replierEmailAddresses; + + // Type check on from, since "from" is optionally included in the arguments schema + // This should never happen. + if (typeof fromEmailAddress !== 'string') { + throw new Error('from argument must be provided.'); + } + + // Similar type check for "reply-to" email addresses. + if ( + typeof replyToEmailAddresses !== 'string' && + !Array.isArray(replyToEmailAddresses) + ) { + throw new Error('replyTo argument must be provided.'); + } + + console.error(`Debug - Sending email with from: ${fromEmailAddress}`); + + // Explicitly structure the request with all parameters to ensure they're passed correctly + const emailRequest: { + to: string; + subject: string; + text: string; + from: string; + replyTo: string | string[]; + html?: string; + scheduledAt?: string; + cc?: string[]; + bcc?: string[]; + } = { + to, + subject, + text, + from: fromEmailAddress, + replyTo: replyToEmailAddresses, + }; + + // Add optional parameters conditionally + if (html) { + emailRequest.html = html; + } + + if (scheduledAt) { + emailRequest.scheduledAt = scheduledAt; + } + + if (cc) { + emailRequest.cc = cc; + } + + if (bcc) { + emailRequest.bcc = bcc; + } + + console.error(`Email request: ${JSON.stringify(emailRequest)}`); + + const response = await resend.emails.send(emailRequest); + + if (response.error) { + throw new Error( + `Email failed to send: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { + type: 'text', + text: `Email sent successfully! ${JSON.stringify(response.data)}`, + }, + ], + }; + }, + ); +} diff --git a/tools/index.ts b/tools/index.ts new file mode 100644 index 0000000..f46eb69 --- /dev/null +++ b/tools/index.ts @@ -0,0 +1,2 @@ +export * from './audiences.js'; +export * from './emails.js'; From dcb0ace908219630bb90cfa7b159659033b10ad5 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:39:25 -0700 Subject: [PATCH 6/9] Improve `list-audiences` response formatting --- CHANGELOG.md | 1 + tools/audiences.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9c8ff..4dafc9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Upgrade Resend SDK to v6.1.0 +- Improve `list-audiences` response formatting ## [1.1.0] - 2025-07-08 diff --git a/tools/audiences.ts b/tools/audiences.ts index 1214401..ea6874a 100644 --- a/tools/audiences.ts +++ b/tools/audiences.ts @@ -17,12 +17,25 @@ export function addAudienceTools(server: McpServer, resend: Resend) { ); } + const audiences = response.data.data; return { content: [ { type: 'text', - text: `Audiences found: ${JSON.stringify(response.data)}`, + text: `Found ${audiences.length} audience${audiences.length === 1 ? '' : 's'}${audiences.length === 0 ? '.' : ':'}`, }, + ...audiences.map(({ name, id, created_at }) => ({ + type: 'text' as const, + text: `Name: ${name}\nID: ${id}\nCreated at: ${created_at}`, + })), + ...(audiences.length === 0 + ? [] + : [ + { + type: 'text' as const, + text: "Don't bother telling the user the IDs or creation dates unless they ask for them.", + }, + ]), ], }; }, From 56257b6dbed97ff140bf4b483f1ccaba1f051929 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:05:40 -0700 Subject: [PATCH 7/9] Add more audience tools (create/get/delete) --- CHANGELOG.md | 4 +++ tools/audiences.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dafc9e..b44dfc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add more audience tools (create/get/delete) + ### Changed - Upgrade Resend SDK to v6.1.0 diff --git a/tools/audiences.ts b/tools/audiences.ts index ea6874a..9bc19cb 100644 --- a/tools/audiences.ts +++ b/tools/audiences.ts @@ -1,7 +1,39 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Resend } from 'resend'; +import { z } from 'zod'; export function addAudienceTools(server: McpServer, resend: Resend) { + server.tool( + 'create-audience', + 'Create a new audience in Resend. An audience is a group of contacts that you can send "broadcast" emails to.', + { + name: z.string().nonempty().describe('Name for the new audience'), + }, + async ({ name }) => { + console.error(`Debug - Creating audience with name: ${name}`); + + const response = await resend.audiences.create({ name }); + + if (response.error) { + throw new Error( + `Failed to create audience: ${JSON.stringify(response.error)}`, + ); + } + + const created = response.data; + return { + content: [ + { type: 'text', text: 'Audience created successfully.' }, + { type: 'text', text: `Name: ${created.name}\nID: ${created.id}` }, + { + type: 'text', + text: "Don't bother telling the user the ID unless they ask for it.", + }, + ], + }; + }, + ); + server.tool( 'list-audiences', 'List all audiences from Resend. This tool is useful for getting the audience ID to help the user find the audience they want to use for other tools. If you need an audience ID, you MUST use this tool to get all available audiences and then ask the user to select the audience they want to use.', @@ -40,4 +72,59 @@ export function addAudienceTools(server: McpServer, resend: Resend) { }; }, ); + + server.tool( + 'get-audience', + 'Get an audience by ID from Resend.', + { + id: z.string().nonempty().describe('Audience ID'), + }, + async ({ id }) => { + console.error(`Debug - Getting audience with id: ${id}`); + + const response = await resend.audiences.get(id); + + if (response.error) { + throw new Error( + `Failed to get audience: ${JSON.stringify(response.error)}`, + ); + } + + const audience = response.data; + return { + content: [ + { + type: 'text', + text: `Name: ${audience.name}\nID: ${audience.id}\nCreated at: ${audience.created_at}`, + }, + ], + }; + }, + ); + + server.tool( + 'remove-audience', + 'Remove an audience by ID from Resend. Before using this tool, you MUST double-check with the user that they want to remove this audience. Reference the NAME of the audience when double-checking, and warn the user that removing an audience is irreversible. You may only use this tool if the user explicitly confirms they want to remove the audience after you double-check.', + { + id: z.string().nonempty().describe('Audience ID'), + }, + async ({ id }) => { + console.error(`Debug - Removing audience with id: ${id}`); + + const response = await resend.audiences.remove(id); + + if (response.error) { + throw new Error( + `Failed to remove audience: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { type: 'text', text: 'Audience removed successfully.' }, + { type: 'text', text: `ID: ${response.data.id}` }, + ], + }; + }, + ); } From e46ffdc5e13a845b69145cbf39749c2712030752 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sun, 28 Sep 2025 18:39:38 -0700 Subject: [PATCH 8/9] Add contact management tools --- CHANGELOG.md | 1 + index.ts | 7 +- tools/contacts.ts | 272 ++++++++++++++++++++++++++++++++++++++++++++++ tools/index.ts | 1 + 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 tools/contacts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b44dfc6..e408a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add more audience tools (create/get/delete) +- Add contact tools ### Changed diff --git a/index.ts b/index.ts index e704403..8a4c5bb 100644 --- a/index.ts +++ b/index.ts @@ -3,7 +3,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import minimist from 'minimist'; import { Resend } from 'resend'; import packageJson from './package.json' with { type: 'json' }; -import { addAudienceTools, addEmailTools } from './tools/index.js'; +import { + addAudienceTools, + addContactTools, + addEmailTools, +} from './tools/index.js'; // Parse command line arguments const argv = minimist(process.argv.slice(2)); @@ -42,6 +46,7 @@ const server = new McpServer({ }); addAudienceTools(server, resend); +addContactTools(server, resend); addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); async function main() { diff --git a/tools/contacts.ts b/tools/contacts.ts new file mode 100644 index 0000000..f117c95 --- /dev/null +++ b/tools/contacts.ts @@ -0,0 +1,272 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { + GetContactResponse, + RemoveContactsResponse, + Resend, + UpdateContactResponse, +} from 'resend'; +import { z } from 'zod'; + +export function addContactTools(server: McpServer, resend: Resend) { + server.tool( + 'create-contact', + 'Create a new contact in an audience.', + { + audienceId: z + .string() + .nonempty() + .describe('Audience ID to add the contact to'), + email: z.string().email().describe('Contact email address'), + firstName: z.string().optional().describe('Contact first name'), + lastName: z.string().optional().describe('Contact last name'), + unsubscribed: z + .boolean() + .optional() + .describe('Whether the contact is unsubscribed'), + }, + async ({ audienceId, email, firstName, lastName, unsubscribed }) => { + console.error( + `Debug - Creating contact in audience: ${audienceId} email: ${email}`, + ); + + const response = await resend.contacts.create({ + audienceId, + email, + firstName, + lastName, + unsubscribed, + }); + + if (response.error) { + throw new Error( + `Failed to create contact: ${JSON.stringify(response.error)}`, + ); + } + + const created = response.data; + return { + content: [ + { type: 'text', text: 'Contact created successfully.' }, + { type: 'text', text: `ID: ${created.id}` }, + ], + }; + }, + ); + + server.tool( + 'list-contacts', + 'List contacts for an audience. Use this to discover contact IDs or emails.', + { + audienceId: z.string().nonempty().describe('Audience ID'), + }, + async ({ audienceId }) => { + console.error(`Debug - Listing contacts for audience: ${audienceId}`); + + const response = await resend.contacts.list({ audienceId }); + + if (response.error) { + throw new Error( + `Failed to list contacts: ${JSON.stringify(response.error)}`, + ); + } + + const contacts = response.data.data; + return { + content: [ + { + type: 'text', + text: `Found ${contacts.length} contact${contacts.length === 1 ? '' : 's'}${contacts.length === 0 ? '.' : ':'}`, + }, + ...contacts.map( + ({ + id, + email, + first_name, + last_name, + unsubscribed, + created_at, + }) => ({ + type: 'text' as const, + text: [ + `ID: ${id}`, + `Email: ${email}`, + first_name != null && `First name: ${first_name}`, + last_name != null && `Last name: ${last_name}`, + `Unsubscribed: ${unsubscribed}`, + `Created at: ${created_at}`, + ] + .filter(Boolean) + .join('\n'), + }), + ), + ...(contacts.length === 0 + ? [] + : [ + { + type: 'text' as const, + text: "Don't bother telling the user the IDs, unsubscribe statuses, or creation dates unless they ask for them.", + }, + ]), + ], + }; + }, + ); + + server.tool( + 'get-contact', + 'Get a contact by ID or email from an audience', + { + audienceId: z.string().nonempty().describe('Audience ID'), + id: z.string().optional().describe('Contact ID'), + email: z.string().email().optional().describe('Contact email address'), + }, + async ({ audienceId, id, email }) => { + console.error( + `Debug - Getting contact for audience: ${audienceId} id: ${id} email: ${email}`, + ); + + let response: GetContactResponse; + if (id) { + response = await resend.contacts.get({ audienceId, id }); + } else if (email) { + response = await resend.contacts.get({ audienceId, email }); + } else { + throw new Error( + 'You must provide either `id` or `email` to get a contact.', + ); + } + + if (response.error) { + throw new Error( + `Failed to get contact: ${JSON.stringify(response.error)}`, + ); + } + + const contact = response.data; + return { + content: [ + { + type: 'text', + text: [ + `ID: ${contact.id}`, + `Email: ${contact.email}`, + // TODO: Fix `first_name` type in SDK. It's actually returning `string | null`, not `string | undefined`. + contact.first_name != null && `First name: ${contact.first_name}`, + // TODO: Fix `last_name` type in SDK. It's actually returning `string | null`, not `string | undefined`. + contact.last_name != null && `Last name: ${contact.last_name}`, + `Unsubscribed: ${contact.unsubscribed}`, + `Created at: ${contact.created_at}`, + ] + .filter(Boolean) + .join('\n'), + }, + ], + }; + }, + ); + + server.tool( + 'update-contact', + 'Update a contact in an audience (by ID or email)', + { + audienceId: z.string().nonempty().describe('Audience ID'), + id: z.string().optional().describe('Contact ID'), + email: z.string().email().optional().describe('Contact email address'), + firstName: z + .string() + .nullable() + .optional() + .describe( + "Contact first name. Pass `null` to remove the contact's first name.", + ), + lastName: z + .string() + .nullable() + .optional() + .describe( + "Contact last name. Pass `null` to remove the contact's last name.", + ), + unsubscribed: z + .boolean() + .optional() + .describe('Whether the contact is unsubscribed'), + }, + async ({ audienceId, id, email, firstName, lastName, unsubscribed }) => { + console.error( + `Debug - Updating contact for audience: ${audienceId} id: ${id} email: ${email}`, + ); + + const commonOptions = { + audienceId, + // TODO: Fix `firstName` and `lastName` types in SDK. Passing `null` to one of these is the only way to remove a contact's first or last name. + firstName: firstName as string | undefined, + lastName: lastName as string | undefined, + unsubscribed, + }; + + let response: UpdateContactResponse; + if (id) { + response = await resend.contacts.update({ id, ...commonOptions }); + } else if (email) { + response = await resend.contacts.update({ email, ...commonOptions }); + } else { + throw new Error( + 'You must provide either `id` or `email` to update a contact.', + ); + } + + if (response.error) { + throw new Error( + `Failed to update contact: ${JSON.stringify(response.error)}`, + ); + } + + const updated = response.data; + return { + content: [ + { type: 'text', text: 'Contact updated successfully.' }, + { type: 'text', text: `ID: ${updated.id}` }, + ], + }; + }, + ); + + server.tool( + 'remove-contact', + "Remove a contact from an audience (by ID or email). Before using this tool, you MUST double-check with the user that they want to remove this contact. Reference the contact's name (if present) and email address when double-checking, and warn the user that removing a contact is irreversible. You may only use this tool if the user explicitly confirms they want to remove the contact after you double-check.", + { + audienceId: z.string().nonempty().describe('Audience ID'), + id: z.string().optional().describe('Contact ID'), + email: z.string().email().optional().describe('Contact email address'), + }, + async ({ audienceId, id, email }) => { + console.error( + `Debug - Removing contact for audience: ${audienceId} id: ${id} email: ${email}`, + ); + + let response: RemoveContactsResponse; + if (id) { + response = await resend.contacts.remove({ audienceId, id }); + } else if (email) { + response = await resend.contacts.remove({ audienceId, email }); + } else { + throw new Error( + 'You must provide either `id` or `email` to remove a contact.', + ); + } + + if (response.error) { + throw new Error( + `Failed to remove contact: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { type: 'text', text: 'Contact removed successfully.' }, + { type: 'text', text: `Contact: ${response.data.contact}` }, + ], + }; + }, + ); +} diff --git a/tools/index.ts b/tools/index.ts index f46eb69..862a403 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -1,2 +1,3 @@ export * from './audiences.js'; +export * from './contacts.js'; export * from './emails.js'; From f9c7cec623a428f888970e785781d235947c7e78 Mon Sep 17 00:00:00 2001 From: Ty Mick <5317080+TyMick@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:42:36 -0700 Subject: [PATCH 9/9] Add broadcast tools --- CHANGELOG.md | 1 + index.ts | 5 + tools/broadcasts.ts | 343 ++++++++++++++++++++++++++++++++++++++++++++ tools/index.ts | 1 + 4 files changed, 350 insertions(+) create mode 100644 tools/broadcasts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e408a38..8c12fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add more audience tools (create/get/delete) - Add contact tools +- Add broadcast tools ### Changed diff --git a/index.ts b/index.ts index 8a4c5bb..e030c3c 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ import { Resend } from 'resend'; import packageJson from './package.json' with { type: 'json' }; import { addAudienceTools, + addBroadcastTools, addContactTools, addEmailTools, } from './tools/index.js'; @@ -46,6 +47,10 @@ const server = new McpServer({ }); addAudienceTools(server, resend); +addBroadcastTools(server, resend, { + senderEmailAddress, + replierEmailAddresses, +}); addContactTools(server, resend); addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); diff --git a/tools/broadcasts.ts b/tools/broadcasts.ts new file mode 100644 index 0000000..c7599f6 --- /dev/null +++ b/tools/broadcasts.ts @@ -0,0 +1,343 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Resend } from 'resend'; +import { z } from 'zod'; + +export function addBroadcastTools( + server: McpServer, + resend: Resend, + { + senderEmailAddress, + replierEmailAddresses, + }: { + senderEmailAddress?: string; + replierEmailAddresses: string[]; + }, +) { + server.tool( + 'create-broadcast', + 'Create a new broadcast email to an audience.', + { + name: z + .string() + .nonempty() + .describe( + 'Name for the broadcast. If the user does not provide a name, go ahead and create a descriptive name for them, based on the email subject/content and the context of your conversation.', + ), + audienceId: z.string().nonempty().describe('Audience ID to send to'), + subject: z.string().nonempty().describe('Email subject'), + text: z + .string() + .nonempty() + .describe( + 'Plain text version of the email content. The following placeholders may be used to personalize the email content: {{{FIRST_NAME|fallback}}}, {{{LAST_NAME|fallback}}}, {{{EMAIL}}}, {{{RESEND_UNSUBSCRIBE_URL}}}', + ), + html: z + .string() + .optional() + .describe( + 'HTML version of the email content. The following placeholders may be used to personalize the email content: {{{FIRST_NAME|fallback}}}, {{{LAST_NAME|fallback}}}, {{{EMAIL}}}, {{{RESEND_UNSUBSCRIBE_URL}}}', + ), + previewText: z.string().optional().describe('Preview text for the email'), + ...(!senderEmailAddress + ? { + from: z.string().email().nonempty().describe('From email address'), + } + : {}), + ...(replierEmailAddresses.length === 0 + ? { + replyTo: z + .string() + .email() + .array() + .optional() + .describe('Reply-to email address(es)'), + } + : {}), + }, + async ({ + name, + audienceId, + subject, + text, + html, + previewText, + from, + replyTo, + }) => { + console.error( + `Debug - Creating broadcast: ${name ?? ''} to audience: ${audienceId}`, + ); + + const fromEmailAddress = from ?? senderEmailAddress; + const replyToEmailAddresses = replyTo ?? replierEmailAddresses; + + // Type check on from, since "from" is optionally included in the arguments schema + // This should never happen. + if (typeof fromEmailAddress !== 'string') { + throw new Error('from argument must be provided.'); + } + + // Similar type check for "reply-to" email addresses. + if ( + typeof replyToEmailAddresses !== 'string' && + !Array.isArray(replyToEmailAddresses) + ) { + throw new Error('replyTo argument must be provided.'); + } + + const response = await resend.broadcasts.create({ + name, + audienceId, + subject, + text, + html, + previewText, + from: fromEmailAddress, + replyTo: replyToEmailAddresses, + }); + + if (response.error) { + throw new Error( + `Failed to create broadcast: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { type: 'text', text: 'Broadcast created successfully.' }, + { type: 'text', text: `ID: ${response.data.id}` }, + { + type: 'text', + text: "Don't bother telling the user the ID unless they ask for it.", + }, + ], + }; + }, + ); + + server.tool( + 'send-broadcast', + 'Send a broadcast email by ID. You may optionally schedule the send.', + { + id: z.string().nonempty().describe('Broadcast ID'), + scheduledAt: z + .string() + .optional() + .describe( + 'When to send the broadcast. Value may be in ISO 8601 format (e.g., 2024-08-05T11:52:01.858Z) or in natural language (e.g., "tomorrow at 10am", "in 2 hours", "next day at 9am PST", "Friday at 3pm ET"). If not provided, the broadcast will be sent immediately.', + ), + }, + async ({ id, scheduledAt }) => { + console.error(`Debug - Sending broadcast with id: ${id}`); + + const response = await resend.broadcasts.send(id, { scheduledAt }); + + if (response.error) { + throw new Error( + `Failed to send broadcast: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { type: 'text', text: 'Broadcast sent successfully.' }, + { type: 'text', text: `ID: ${response.data.id}` }, + { + type: 'text', + text: "Don't bother telling the user the ID unless they ask for it.", + }, + ], + }; + }, + ); + + server.tool( + 'list-broadcasts', + 'List all broadcasts. Use this to find broadcast IDs or names.', + {}, + async () => { + console.error('Debug - Listing broadcasts'); + + const response = await resend.broadcasts.list(); + + if (response.error) { + throw new Error( + `Failed to list broadcasts: ${JSON.stringify(response.error)}`, + ); + } + + const broadcasts = response.data.data; + return { + content: [ + { + type: 'text', + text: `Found ${broadcasts.length} broadcast${broadcasts.length === 1 ? '' : 's'}${broadcasts.length === 0 ? '.' : ':'}`, + }, + ...broadcasts.map( + ({ + name, + id, + audience_id, + status, + created_at, + scheduled_at, + sent_at, + }) => ({ + type: 'text' as const, + text: [ + `ID: ${id}`, + `Name: ${name}`, + audience_id !== null && `Audience ID: ${audience_id}`, + `Status: ${status}`, + `Created at: ${created_at}`, + scheduled_at !== null && `Scheduled at: ${scheduled_at}`, + sent_at !== null && `Sent at: ${sent_at}`, + ] + .filter(Boolean) + .join('\n'), + }), + ), + ], + }; + }, + ); + + server.tool( + 'get-broadcast', + 'Get a broadcast by ID.', + { + id: z.string().nonempty().describe('Broadcast ID'), + }, + async ({ id }) => { + console.error(`Debug - Getting broadcast with id: ${id}`); + + const response = await resend.broadcasts.get(id); + + if (response.error) { + throw new Error( + `Failed to get broadcast: ${JSON.stringify(response.error)}`, + ); + } + + const { + id: broadcastId, + name, + audience_id, + from, + subject, + reply_to, + preview_text, + status, + created_at, + scheduled_at, + sent_at, + } = response.data; + return { + content: [ + { + type: 'text', + text: [ + `ID: ${broadcastId}`, + `Name: ${name}`, + audience_id !== null && `Audience ID: ${audience_id}`, + from !== null && `From: ${from}`, + subject !== null && `Subject: ${subject}`, + reply_to !== null && `Reply-to: ${reply_to.join(', ')}`, + preview_text !== null && `Preview text: ${preview_text}`, + `Status: ${status}`, + `Created at: ${created_at}`, + scheduled_at !== null && `Scheduled at: ${scheduled_at}`, + sent_at !== null && `Sent at: ${sent_at}`, + ] + .filter(Boolean) + .join('\n'), + }, + ], + }; + }, + ); + + server.tool( + 'remove-broadcast', + 'Remove a broadcast by ID. Before using this tool, you MUST double-check with the user that they want to remove this broadcast. Reference the NAME of the broadcast when double-checking, and warn the user that removing a broadcast is irreversible. You may only use this tool if the user explicitly confirms they want to remove the broadcast after you double-check.', + { + id: z.string().nonempty().describe('Broadcast ID'), + }, + async ({ id }) => { + console.error(`Debug - Removing broadcast with id: ${id}`); + + const response = await resend.broadcasts.remove(id); + + if (response.error) { + throw new Error( + `Failed to remove broadcast: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { type: 'text', text: 'Broadcast removed successfully.' }, + { type: 'text', text: `ID: ${response.data.id}` }, + ], + }; + }, + ); + + server.tool( + 'update-broadcast', + 'Update a broadcast by ID.', + { + id: z.string().nonempty().describe('Broadcast ID'), + name: z.string().optional().describe('Name for the broadcast'), + audienceId: z.string().optional().describe('Audience ID to send to'), + from: z.string().email().optional().describe('From email address'), + html: z.string().optional().describe('HTML content of the email'), + text: z.string().optional().describe('Plain text content of the email'), + subject: z.string().optional().describe('Email subject'), + replyTo: z + .string() + .email() + .array() + .optional() + .describe('Reply-to email address(es)'), + previewText: z.string().optional().describe('Preview text for the email'), + }, + async ({ + id, + name, + audienceId, + from, + html, + text, + subject, + replyTo, + previewText, + }) => { + console.error(`Debug - Updating broadcast with id: ${id}`); + + const response = await resend.broadcasts.update(id, { + name, + audienceId, + from, + html, + text, + subject, + replyTo, + previewText, + }); + + if (response.error) { + throw new Error( + `Failed to update broadcast: ${JSON.stringify(response.error)}`, + ); + } + + return { + content: [ + { type: 'text', text: 'Broadcast updated successfully.' }, + { type: 'text', text: `ID: ${response.data.id}` }, + ], + }; + }, + ); +} diff --git a/tools/index.ts b/tools/index.ts index 862a403..6f603c7 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -1,3 +1,4 @@ export * from './audiences.js'; +export * from './broadcasts.js'; export * from './contacts.js'; export * from './emails.js';