diff --git a/CHANGELOG.md b/CHANGELOG.md index 434ea2f..8c12fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,50 @@ 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] + +### Added + +- Add more audience tools (create/get/delete) +- Add contact tools +- Add broadcast tools + +### Changed + +- Upgrade Resend SDK to v6.1.0 +- Improve `list-audiences` response formatting + ## [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 diff --git a/index.ts b/index.ts index fd61ac5..e030c3c 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,13 @@ 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, + addBroadcastTools, + addContactTools, + addEmailTools, +} from './tools/index.js'; // Parse command line arguments const argv = minimist(process.argv.slice(2)); @@ -37,171 +43,16 @@ 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( - '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); +addBroadcastTools(server, resend, { + senderEmailAddress, + replierEmailAddresses, +}); +addContactTools(server, resend); +addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); async function main() { const transport = new StdioServerTransport(); diff --git a/package-lock.json b/package-lock.json index 8e0ff29..f8a20ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "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", "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 f23d902..c97dfa6 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": "", @@ -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": { diff --git a/tools/audiences.ts b/tools/audiences.ts new file mode 100644 index 0000000..9bc19cb --- /dev/null +++ b/tools/audiences.ts @@ -0,0 +1,130 @@ +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.', + {}, + 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)}`, + ); + } + + const audiences = response.data.data; + return { + content: [ + { + type: 'text', + 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.", + }, + ]), + ], + }; + }, + ); + + 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}` }, + ], + }; + }, + ); +} 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/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/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..6f603c7 --- /dev/null +++ b/tools/index.ts @@ -0,0 +1,4 @@ +export * from './audiences.js'; +export * from './broadcasts.js'; +export * from './contacts.js'; +export * from './emails.js'; 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": ".",