diff --git a/.env.example b/.env.example index 9694ae4..9d01f81 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ RESEND_API_KEY= SENDER_EMAIL_ADDRESS= -REPLY_TO_EMAIL_ADDRESSES= \ No newline at end of file +REPLY_TO_EMAIL_ADDRESSES= +MCP_PORT=3000 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e063ee..bafdb74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,5 +25,7 @@ jobs: uses: pnpm/action-setup@1e1c8eafbd745f64b1ef30a7d7ed7965034c486c - name: Install packages run: pnpm install --frozen-lockfile --prefer-offline + - name: Run tests + run: pnpm test - name: Run Build run: pnpm build diff --git a/README.md b/README.md index dc736f3..dcc5373 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,17 @@ Create a free Resend account and [create an API key](https://resend.com/api-keys ## Usage -### Claude Code +The server supports two transport modes: **stdio** (default) and **HTTP**. + +### Stdio Transport (Default) + +#### Claude Code ```bash claude mcp add resend -e RESEND_API_KEY=re_xxxxxxxxx -- npx -y resend-mcp ``` -### Cursor +#### Cursor Open the command palette and choose "Cursor Settings" > "MCP" > "Add new global MCP server". @@ -47,7 +51,7 @@ Open the command palette and choose "Cursor Settings" > "MCP" > "Add new global } ``` -### Claude Desktop +#### Claude Desktop Open Claude Desktop settings > "Developer" tab > "Edit Config". @@ -65,6 +69,25 @@ Open Claude Desktop settings > "Developer" tab > "Edit Config". } ``` +### HTTP Transport + +Run the server over HTTP for integration with web-based agents: + +```bash +npx -y resend-mcp --http --port 3000 +``` + +The server will listen on `http://127.0.0.1:3000` and expose the MCP endpoint at `/mcp` using Streamable HTTP. Configure your web-based agent to connect to this endpoint. + +You can also set the port via the `MCP_PORT` environment variable: + +```bash +MCP_PORT=3000 npx -y resend-mcp --http +``` + +> [!NOTE] +> The HTTP transport uses session-based connections. Include the `mcp-session-id` header in your requests after the initial initialization. + ### Options You can pass additional arguments to configure the server: @@ -72,28 +95,30 @@ You can pass additional arguments to configure the server: - `--key`: Your Resend API key (alternative to `RESEND_API_KEY` env var) - `--sender`: Default sender email address from a verified domain - `--reply-to`: Default reply-to email address (can be specified multiple times) +- `--http`: Use HTTP transport instead of stdio (default: stdio) +- `--port`: HTTP port when using `--http` (default: 3000, or `MCP_PORT` env var) Environment variables: - `RESEND_API_KEY`: Your Resend API key (required) - `SENDER_EMAIL_ADDRESS`: Default sender email address from a verified domain (optional) - `REPLY_TO_EMAIL_ADDRESSES`: Comma-separated reply-to email addresses (optional) +- `MCP_PORT`: HTTP port when using `--http` (optional) > [!NOTE] -> If you don't provide a sender email address, the MCP server will ask for one each time you send an email. +> If you don't provide a sender email address, the MCP server will ask you to provide one each time you call the tool. ## Local Development -1. Clone and build: +1. Clone this project and build: -```bash +``` git clone https://github.com/resend/resend-mcp.git -cd resend-mcp pnpm install pnpm run build ``` -2. Use the local build in your MCP client: +2. To use the local build in Cursor or Claude Desktop, replace the `npx` command with the path to your local build: ```json { @@ -113,6 +138,8 @@ pnpm run build > **Note:** Make sure you've built the project first (see [Local Development](#local-development) section above). +#### Using Stdio Transport + 1. Set your API key: ```bash @@ -132,3 +159,24 @@ pnpm run build - **Args:** `dist/index.js` (or the full path to `dist/index.js`) - **Env:** `RESEND_API_KEY=re_your_key_here` (or leave blank if you already exported it in the same terminal). - Click **Connect**, then use "List tools" to verify the server is working. + +#### Using HTTP Transport + +1. Start the HTTP server in one terminal: + + ```bash + export RESEND_API_KEY=re_your_key_here + node dist/index.js --http --port 3000 + ``` + +2. Start the inspector in another terminal: + + ```bash + pnpm inspector + ``` + +3. In the browser (Inspector UI): + + - Choose **Streamable HTTP** (connect to URL). + - **URL:** `http://127.0.0.1:3000/mcp` + - Click **Connect**, then use "List tools" to verify the server is working. diff --git a/index.ts b/index.ts deleted file mode 100644 index 249f926..0000000 --- a/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -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 packageJson from './package.json' with { type: 'json' }; -import { - addApiKeyTools, - addBroadcastTools, - addContactPropertyTools, - addContactTools, - addDomainTools, - addEmailTools, - addSegmentTools, - addTopicTools, - addWebhookTools, -} from './tools/index.js'; - -// Parse command line arguments -const argv = minimist(process.argv.slice(2)); - -// Get API key from command line argument or fall back to environment variable -const apiKey = argv.key || process.env.RESEND_API_KEY; - -// Get sender email address from command line argument or fall back to environment variable -// Optional. -const senderEmailAddress = argv.sender || process.env.SENDER_EMAIL_ADDRESS; - -// Get reply to email addresses from command line argument or fall back to environment variable -let replierEmailAddresses: string[] = []; - -if (Array.isArray(argv['reply-to'])) { - replierEmailAddresses = argv['reply-to']; -} else if (typeof argv['reply-to'] === 'string') { - replierEmailAddresses = [argv['reply-to']]; -} else if (process.env.REPLY_TO_EMAIL_ADDRESSES) { - replierEmailAddresses = process.env.REPLY_TO_EMAIL_ADDRESSES.split(','); -} - -if (!apiKey) { - console.error( - 'No API key provided. Please set RESEND_API_KEY environment variable or use --key argument', - ); - process.exit(1); -} - -const resend = new Resend(apiKey); - -// Create server instance -const server = new McpServer({ - name: 'resend', - version: packageJson.version, -}); - -addApiKeyTools(server, resend); -addBroadcastTools(server, resend, { - senderEmailAddress, - replierEmailAddresses, -}); -addContactPropertyTools(server, resend); -addContactTools(server, resend); -addDomainTools(server, resend); -addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); -addSegmentTools(server, resend); -addTopicTools(server, resend); -addWebhookTools(server, resend); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('Resend MCP Server running on stdio'); -} - -main().catch((error) => { - console.error('Fatal error in main():', error); - process.exit(1); -}); diff --git a/package.json b/package.json index f5e0c3f..310b3ee 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,13 @@ "prepare": "npm run build", "inspector": "npx @modelcontextprotocol/inspector@latest", "lint": "biome check .", - "lint:fix": "biome check . --write" + "lint:fix": "biome check . --write", + "test": "vitest run" }, "dependencies": { "@modelcontextprotocol/sdk": "1.26.0", + "dotenv": "17.3.1", + "express": "5.2.1", "minimist": "1.2.8", "resend": "6.9.2", "zod": "4.3.6" @@ -29,7 +32,8 @@ "@biomejs/biome": "2.4.1", "@types/minimist": "1.2.5", "@types/node": "25.0.3", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "3.2.4" }, "packageManager": "pnpm@10.29.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80957e8..6955860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.26.0 version: 1.26.0(zod@4.3.6) + dotenv: + specifier: 17.3.1 + version: 17.3.1 + express: + specifier: 5.2.1 + version: 5.2.1 minimist: specifier: 1.2.8 version: 1.2.8 @@ -33,6 +39,9 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@25.0.3) packages: @@ -93,12 +102,171 @@ packages: cpu: [x64] os: [win32] + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -116,18 +284,194 @@ packages: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -143,6 +487,10 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -151,6 +499,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -159,6 +511,14 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -192,6 +552,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -213,6 +577,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -236,13 +604,24 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -255,6 +634,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.2.1: resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} engines: {node: '>= 16'} @@ -277,6 +660,15 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -289,6 +681,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -351,6 +748,9 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -360,6 +760,12 @@ packages: leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -386,6 +792,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -419,9 +830,23 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -429,6 +854,10 @@ packages: postal-mime@2.7.3: resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + prettier@3.7.4: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} @@ -475,6 +904,11 @@ packages: '@react-email/render': optional: true + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -523,6 +957,16 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -530,9 +974,37 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + svix@1.84.1: resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -561,11 +1033,89 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -614,10 +1164,90 @@ snapshots: '@biomejs/cli-win32-x64@2.4.1': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + '@hono/node-server@1.19.9(hono@4.11.9)': dependencies: hono: 4.11.9 + '@jridgewell/sourcemap-codec@1.5.5': {} + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.9) @@ -649,6 +1279,81 @@ snapshots: react-promise-suspense: 0.3.4 optional: true + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -657,12 +1362,63 @@ snapshots: '@stablelib/base64@1.0.1': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/minimist@1.2.5': {} '@types/node@25.0.3': dependencies: undici-types: 7.16.0 + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.3) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -679,6 +1435,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + assertion-error@2.0.1: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -695,6 +1453,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -705,6 +1465,16 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -728,6 +1498,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deepmerge@4.3.1: optional: true @@ -755,6 +1527,8 @@ snapshots: domhandler: 5.0.3 optional: true + dotenv@17.3.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -772,12 +1546,47 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} eventsource-parser@3.0.6: {} @@ -786,6 +1595,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + expect-type@1.3.0: {} + express-rate-limit@8.2.1(express@5.2.1): dependencies: express: 5.2.1 @@ -833,6 +1644,10 @@ snapshots: fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -848,6 +1663,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -919,6 +1737,8 @@ snapshots: jose@6.1.3: {} + js-tokens@9.0.1: {} + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -926,6 +1746,12 @@ snapshots: leac@0.6.0: optional: true + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -942,6 +1768,8 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.11: {} + negotiator@1.0.0: {} object-assign@4.1.1: {} @@ -968,13 +1796,27 @@ snapshots: path-to-regexp@8.3.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + peberminta@0.9.0: optional: true + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + pkce-challenge@5.0.1: {} postal-mime@2.7.3: {} + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prettier@3.7.4: optional: true @@ -1019,6 +1861,37 @@ snapshots: optionalDependencies: '@react-email/render': 1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -1100,6 +1973,12 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 @@ -1107,11 +1986,32 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + svix@1.84.1: dependencies: standardwebhooks: 1.0.0 uuid: 10.0.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + toidentifier@1.0.1: {} type-is@2.0.1: @@ -1130,10 +2030,89 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@25.0.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.0.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@25.0.3): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@25.0.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@25.0.3) + vite-node: 3.2.4(@types/node@25.0.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} zod-to-json-schema@3.25.1(zod@4.3.6): diff --git a/src/cli/constants.ts b/src/cli/constants.ts new file mode 100644 index 0000000..70778fb --- /dev/null +++ b/src/cli/constants.ts @@ -0,0 +1,8 @@ +export const CLI_STRING_OPTIONS = [ + 'key', + 'sender', + 'reply-to', + 'port', +] as const; + +export const DEFAULT_HTTP_PORT = 3000; diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..76fb68e --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,26 @@ +export const HELP_TEXT = ` +Resend MCP server + +Usage: + resend-mcp [options] + npx resend-mcp [options] + RESEND_API_KEY=re_xxx resend-mcp [options] + +Options: + --key Resend API key (or set RESEND_API_KEY) + --sender Default from address (or SENDER_EMAIL_ADDRESS) + --reply-to Reply-to; repeat for multiple (or REPLY_TO_EMAIL_ADDRESSES) + --http Run HTTP server (Streamable HTTP at /mcp) instead of stdio + --port HTTP port when using --http (default: 3000, or MCP_PORT) + -h, --help Show this help + +Environment: + RESEND_API_KEY Required if --key not set + SENDER_EMAIL_ADDRESS Optional + REPLY_TO_EMAIL_ADDRESSES Optional, comma-separated + MCP_PORT HTTP port when using --http (optional) +`.trim(); + +export function printHelp(): void { + console.error(HELP_TEXT); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..e228941 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,29 @@ +import type { ParsedArgs } from 'minimist'; +import { printHelp } from './help.js'; +import { resolveConfig } from './resolve.js'; +import type { CliConfig } from './types.js'; + +/** + * Resolve config from argv and env, or print help/error and exit. + */ +export function resolveConfigOrExit( + argv: ParsedArgs, + env: NodeJS.ProcessEnv = process.env, +): CliConfig { + if (argv.help === true || argv.h === true) { + printHelp(); + process.exit(0); + } + + const result = resolveConfig(argv, env); + if (!result.ok) { + console.error('Error:', result.error); + process.exit(1); + } + return result.config; +} + +export { HELP_TEXT, printHelp } from './help.js'; +export { parseArgs } from './parse.js'; +export { resolveConfig } from './resolve.js'; +export * from './types.js'; diff --git a/src/cli/parse.ts b/src/cli/parse.ts new file mode 100644 index 0000000..655f152 --- /dev/null +++ b/src/cli/parse.ts @@ -0,0 +1,33 @@ +import type { ParsedArgs } from 'minimist'; +import minimist from 'minimist'; +import { CLI_STRING_OPTIONS } from './constants.js'; + +/** + * Parse process.argv with minimist. Does not read env or validate. + */ +export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs { + return minimist(argv, { + string: [...CLI_STRING_OPTIONS], + boolean: ['help', 'http'], + alias: { h: 'help' }, + }); +} + +/** + * Parse reply-to from argv and env. argv wins. + */ +export function parseReplierAddresses( + parsed: ParsedArgs, + env: NodeJS.ProcessEnv, +): string[] { + if (Array.isArray(parsed['reply-to'])) return parsed['reply-to']; + if (typeof parsed['reply-to'] === 'string') return [parsed['reply-to']]; + const v = env.REPLY_TO_EMAIL_ADDRESSES; + if (typeof v === 'string' && v.trim()) { + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + return []; +} diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts new file mode 100644 index 0000000..73aa2c6 --- /dev/null +++ b/src/cli/resolve.ts @@ -0,0 +1,62 @@ +import type { ParsedArgs } from 'minimist'; +import { DEFAULT_HTTP_PORT } from './constants.js'; +import { parseReplierAddresses } from './parse.js'; +import type { ResolveResult } from './types.js'; + +function parsePort(parsed: ParsedArgs, env: NodeJS.ProcessEnv): number { + const fromArg = + typeof parsed.port === 'string' && parsed.port.trim() !== '' + ? Number.parseInt(parsed.port.trim(), 10) + : NaN; + if (Number.isInteger(fromArg) && fromArg > 0 && fromArg < 65536) + return fromArg; + const fromEnv = + typeof env.MCP_PORT === 'string' && env.MCP_PORT.trim() !== '' + ? Number.parseInt(env.MCP_PORT.trim(), 10) + : NaN; + if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) + return fromEnv; + return DEFAULT_HTTP_PORT; +} + +/** + * Resolve config from parsed argv and env. No side effects, no exit. + */ +export function resolveConfig( + parsed: ParsedArgs, + env: NodeJS.ProcessEnv = process.env, +): ResolveResult { + const apiKey = + (typeof parsed.key === 'string' ? parsed.key : null) ?? + env.RESEND_API_KEY ?? + null; + + if (!apiKey || !apiKey.trim()) { + return { + ok: false, + error: + 'No API key. Set RESEND_API_KEY or use --key=', + }; + } + + const senderEmailAddress = + (typeof parsed.sender === 'string' ? parsed.sender : null) ?? + (typeof env.SENDER_EMAIL_ADDRESS === 'string' + ? env.SENDER_EMAIL_ADDRESS.trim() || undefined + : undefined); + + const http = parsed.http === true || parsed['http'] === true; + const transport = http ? 'http' : 'stdio'; + const port = parsePort(parsed, env); + + return { + ok: true, + config: { + apiKey: apiKey.trim(), + senderEmailAddress: senderEmailAddress ?? '', + replierEmailAddresses: parseReplierAddresses(parsed, env), + transport, + port, + }, + }; +} diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..9a450fb --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,13 @@ +export type TransportMode = 'stdio' | 'http'; + +export interface CliConfig { + apiKey: string; + senderEmailAddress: string; + replierEmailAddresses: string[]; + transport: TransportMode; + port: number; +} + +export type ResolveResult = + | { ok: true; config: CliConfig } + | { ok: false; error: string }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dbccb04 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import 'dotenv/config'; +import { Resend } from 'resend'; +import { parseArgs, resolveConfigOrExit } from './cli/index.js'; +import { runHttp } from './transports/http.js'; +import { runStdio } from './transports/stdio.js'; + +const parsed = parseArgs(process.argv.slice(2)); +const config = resolveConfigOrExit(parsed, process.env); +const resend = new Resend(config.apiKey); +const serverOptions = { + senderEmailAddress: config.senderEmailAddress, + replierEmailAddresses: config.replierEmailAddresses, +}; + +function onFatal(err: unknown): void { + console.error('Fatal error:', err); + if (err instanceof Error && err.stack) { + console.error(err.stack); + } + process.exit(1); +} + +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); + +if (config.transport === 'http') { + runHttp(resend, serverOptions, config.port).catch(onFatal); +} else { + runStdio(resend, serverOptions).catch(onFatal); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..364b8d6 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,41 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Resend } from 'resend'; +import packageJson from '../package.json' with { type: 'json' }; +import { + addApiKeyTools, + addBroadcastTools, + addContactPropertyTools, + addContactTools, + addDomainTools, + addEmailTools, + addSegmentTools, + addTopicTools, + addWebhookTools, +} from './tools/index.js'; +import type { ServerOptions } from './types.js'; + +export type { ServerOptions } from './types.js'; + +export function createMcpServer( + resend: Resend, + options: ServerOptions, +): McpServer { + const { senderEmailAddress, replierEmailAddresses } = options; + const server = new McpServer({ + name: 'resend', + version: packageJson.version, + }); + addApiKeyTools(server, resend); + addBroadcastTools(server, resend, { + senderEmailAddress, + replierEmailAddresses, + }); + addContactPropertyTools(server, resend); + addContactTools(server, resend); + addDomainTools(server, resend); + addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); + addSegmentTools(server, resend); + addTopicTools(server, resend); + addWebhookTools(server, resend); + return server; +} diff --git a/tools/apiKeys.ts b/src/tools/apiKeys.ts similarity index 100% rename from tools/apiKeys.ts rename to src/tools/apiKeys.ts diff --git a/tools/broadcasts.ts b/src/tools/broadcasts.ts similarity index 100% rename from tools/broadcasts.ts rename to src/tools/broadcasts.ts diff --git a/tools/contactProperties.ts b/src/tools/contactProperties.ts similarity index 100% rename from tools/contactProperties.ts rename to src/tools/contactProperties.ts diff --git a/tools/contacts.ts b/src/tools/contacts.ts similarity index 100% rename from tools/contacts.ts rename to src/tools/contacts.ts diff --git a/tools/domains.ts b/src/tools/domains.ts similarity index 100% rename from tools/domains.ts rename to src/tools/domains.ts diff --git a/tools/emails.ts b/src/tools/emails.ts similarity index 100% rename from tools/emails.ts rename to src/tools/emails.ts diff --git a/tools/index.ts b/src/tools/index.ts similarity index 100% rename from tools/index.ts rename to src/tools/index.ts diff --git a/tools/segments.ts b/src/tools/segments.ts similarity index 100% rename from tools/segments.ts rename to src/tools/segments.ts diff --git a/tools/topics.ts b/src/tools/topics.ts similarity index 100% rename from tools/topics.ts rename to src/tools/topics.ts diff --git a/tools/webhooks.ts b/src/tools/webhooks.ts similarity index 100% rename from tools/webhooks.ts rename to src/tools/webhooks.ts diff --git a/src/transports/http.ts b/src/transports/http.ts new file mode 100644 index 0000000..ccd1e1b --- /dev/null +++ b/src/transports/http.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { Resend } from 'resend'; +import { createMcpServer } from '../server.js'; +import type { ServerOptions } from '../types.js'; + +const sessions: Record = {}; + +function sendJsonRpcError(res: ServerResponse, message: string): void { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message }, + id: null, + }), + ); +} + +export async function runHttp( + resend: Resend, + options: ServerOptions, + port: number, +): Promise { + const getServer = (): McpServer => createMcpServer(resend, options); + + const app = createMcpExpressApp(); + + app.all( + '/mcp', + async (req: IncomingMessage & { body?: unknown }, res: ServerResponse) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport | undefined; + + if (sessionId && sessions[sessionId]) { + transport = sessions[sessionId]; + } else if ( + !sessionId && + req.method === 'POST' && + isInitializeRequest(req.body) + ) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + sessions[sid] = transport!; + }, + }); + transport.onclose = () => { + const sid = transport!.sessionId; + if (sid && sessions[sid]) delete sessions[sid]; + }; + const server = getServer(); + await server.connect(transport); + } else if (sessionId && !sessions[sessionId]) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Session not found' }, + id: null, + }), + ); + return; + } else { + sendJsonRpcError(res, 'Bad Request: No valid session ID provided'); + return; + } + + await transport.handleRequest(req, res, req.body); + }, + ); + + return new Promise((resolve, reject) => { + const server = app.listen(port, () => { + console.error(`Resend MCP server listening on http://127.0.0.1:${port}`); + console.error(' Streamable HTTP: POST/GET/DELETE /mcp'); + resolve(); + }); + server.once('error', reject); + + const shutdown = async () => { + for (const sid of Object.keys(sessions)) { + try { + await sessions[sid].close(); + } catch { + // ignore + } + delete sessions[sid]; + } + server.close(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + }); +} diff --git a/src/transports/stdio.ts b/src/transports/stdio.ts new file mode 100644 index 0000000..37c021a --- /dev/null +++ b/src/transports/stdio.ts @@ -0,0 +1,14 @@ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { Resend } from 'resend'; +import { createMcpServer } from '../server.js'; +import type { ServerOptions } from '../types.js'; + +export async function runStdio( + resend: Resend, + options: ServerOptions, +): Promise { + const server = createMcpServer(resend, options); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Resend MCP Server running on stdio'); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2ac9899 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export interface ServerOptions { + senderEmailAddress?: string; + replierEmailAddresses: string[]; +} diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts new file mode 100644 index 0000000..5745f41 --- /dev/null +++ b/tests/cli/help.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { HELP_TEXT, printHelp } from '../../src/cli/help.js'; + +describe('help', () => { + it('HELP_TEXT includes usage and main options', () => { + expect(HELP_TEXT).toContain('Usage:'); + expect(HELP_TEXT).toContain('resend-mcp'); + expect(HELP_TEXT).toContain('--key'); + expect(HELP_TEXT).toContain('--sender'); + expect(HELP_TEXT).toContain('--reply-to'); + expect(HELP_TEXT).toContain('--http'); + expect(HELP_TEXT).toContain('--port'); + expect(HELP_TEXT).toContain('-h, --help'); + expect(HELP_TEXT).toContain('RESEND_API_KEY'); + expect(HELP_TEXT).toContain('MCP_PORT'); + }); + + it('printHelp writes HELP_TEXT to console.error', () => { + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + printHelp(); + expect(stderr).toHaveBeenCalledWith(HELP_TEXT); + stderr.mockRestore(); + }); +}); diff --git a/tests/cli/parse.test.ts b/tests/cli/parse.test.ts new file mode 100644 index 0000000..ca9f1ab --- /dev/null +++ b/tests/cli/parse.test.ts @@ -0,0 +1,94 @@ +import type { ParsedArgs } from 'minimist'; +import { describe, expect, it } from 'vitest'; +import { parseArgs, parseReplierAddresses } from '../../src/cli/parse.js'; + +describe('parseArgs', () => { + it('parses empty argv', () => { + const parsed = parseArgs([]); + expect(parsed._).toEqual([]); + expect(parsed.key).toBeUndefined(); + expect(parsed.sender).toBeUndefined(); + expect(parsed['reply-to']).toBeUndefined(); + }); + + it('parses --key', () => { + const parsed = parseArgs(['--key', 're_abc']); + expect(parsed.key).toBe('re_abc'); + }); + + it('parses --sender', () => { + const parsed = parseArgs(['--sender', 'onboarding@resend.dev']); + expect(parsed.sender).toBe('onboarding@resend.dev'); + }); + + it('parses -h and --help', () => { + expect(parseArgs(['-h']).help).toBe(true); + expect(parseArgs(['--help']).help).toBe(true); + }); + + it('parses single --reply-to', () => { + const parsed = parseArgs(['--reply-to', 'reply@example.com']); + expect(parsed['reply-to']).toBe('reply@example.com'); + }); + + it('parses multiple --reply-to into array', () => { + const parsed = parseArgs([ + '--reply-to', + 'a@x.com', + '--reply-to', + 'b@x.com', + ]); + expect(parsed['reply-to']).toEqual(['a@x.com', 'b@x.com']); + }); + + it('parses --http as boolean', () => { + expect(parseArgs(['--http']).http).toBe(true); + }); + + it('parses --port', () => { + const parsed = parseArgs(['--port', '8080']); + expect(parsed.port).toBe('8080'); + }); +}); + +describe('parseReplierAddresses', () => { + it('returns array from parsed reply-to array', () => { + const parsed: ParsedArgs = { _: [], 'reply-to': ['a@x.com', 'b@x.com'] }; + expect(parseReplierAddresses(parsed, {})).toEqual(['a@x.com', 'b@x.com']); + }); + + it('returns single-element array from parsed reply-to string', () => { + const parsed: ParsedArgs = { _: [], 'reply-to': 'one@x.com' }; + expect(parseReplierAddresses(parsed, {})).toEqual(['one@x.com']); + }); + + it('uses env REPLY_TO_EMAIL_ADDRESSES when reply-to not in argv', () => { + const parsed: ParsedArgs = { _: [] }; + expect( + parseReplierAddresses(parsed, { + REPLY_TO_EMAIL_ADDRESSES: 'a@x.com,b@x.com', + }), + ).toEqual(['a@x.com', 'b@x.com']); + }); + + it('trims and filters empty env values', () => { + const parsed: ParsedArgs = { _: [] }; + expect( + parseReplierAddresses(parsed, { + REPLY_TO_EMAIL_ADDRESSES: ' a@x.com , , b@x.com ', + }), + ).toEqual(['a@x.com', 'b@x.com']); + }); + + it('returns empty array when neither argv nor env set', () => { + const parsed: ParsedArgs = { _: [] }; + expect(parseReplierAddresses(parsed, {})).toEqual([]); + }); + + it('argv wins over env', () => { + const parsed: ParsedArgs = { _: [], 'reply-to': 'cli@x.com' }; + expect( + parseReplierAddresses(parsed, { REPLY_TO_EMAIL_ADDRESSES: 'env@x.com' }), + ).toEqual(['cli@x.com']); + }); +}); diff --git a/tests/cli/resolve.test.ts b/tests/cli/resolve.test.ts new file mode 100644 index 0000000..980dce7 --- /dev/null +++ b/tests/cli/resolve.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { parseArgs } from '../../src/cli/parse.js'; +import { resolveConfig } from '../../src/cli/resolve.js'; + +describe('resolveConfig', () => { + it('returns error when no API key', () => { + const parsed = parseArgs([]); + const result = resolveConfig(parsed, {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('API key'); + } + }); + + it('returns error when API key is whitespace only', () => { + const result = resolveConfig(parseArgs(['--key', ' ']), { + RESEND_API_KEY: ' ', + }); + expect(result.ok).toBe(false); + }); + + it('resolves config from --key', () => { + const parsed = parseArgs(['--key', ' re_abc ']); + const result = resolveConfig(parsed, {}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.apiKey).toBe('re_abc'); + expect(result.config.senderEmailAddress).toBe(''); + expect(result.config.replierEmailAddresses).toEqual([]); + } + }); + + it('resolves config from RESEND_API_KEY when --key not set', () => { + const parsed = parseArgs([]); + const result = resolveConfig(parsed, { RESEND_API_KEY: 're_env' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.apiKey).toBe('re_env'); + } + }); + + it('--key overrides RESEND_API_KEY', () => { + const parsed = parseArgs(['--key', 're_cli']); + const result = resolveConfig(parsed, { RESEND_API_KEY: 're_env' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.apiKey).toBe('re_cli'); + } + }); + + it('includes sender from --sender', () => { + const parsed = parseArgs(['--key', 're_x', '--sender', 'from@resend.dev']); + const result = resolveConfig(parsed, {}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.senderEmailAddress).toBe('from@resend.dev'); + } + }); + + it('includes sender from SENDER_EMAIL_ADDRESS', () => { + const parsed = parseArgs(['--key', 're_x']); + const result = resolveConfig(parsed, { + RESEND_API_KEY: 're_x', + SENDER_EMAIL_ADDRESS: ' env@resend.dev ', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.senderEmailAddress).toBe('env@resend.dev'); + } + }); + + it('defaults sender to empty string when not set', () => { + const parsed = parseArgs(['--key', 're_x']); + const result = resolveConfig(parsed, { RESEND_API_KEY: 're_x' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.senderEmailAddress).toBe(''); + } + }); + + it('includes replier addresses from env', () => { + const parsed = parseArgs(['--key', 're_x']); + const result = resolveConfig(parsed, { + RESEND_API_KEY: 're_x', + REPLY_TO_EMAIL_ADDRESSES: 'r1@x.com,r2@x.com', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.replierEmailAddresses).toEqual([ + 'r1@x.com', + 'r2@x.com', + ]); + } + }); + + it('defaults transport to stdio and port to 3000', () => { + const parsed = parseArgs(['--key', 're_x']); + const result = resolveConfig(parsed, { RESEND_API_KEY: 're_x' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.transport).toBe('stdio'); + expect(result.config.port).toBe(3000); + } + }); + + it('sets transport to http and uses default port when --http', () => { + const parsed = parseArgs(['--key', 're_x', '--http']); + const result = resolveConfig(parsed, { RESEND_API_KEY: 're_x' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.transport).toBe('http'); + expect(result.config.port).toBe(3000); + } + }); + + it('uses --port when provided with --http', () => { + const parsed = parseArgs(['--key', 're_x', '--http', '--port', '8080']); + const result = resolveConfig(parsed, { RESEND_API_KEY: 're_x' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.transport).toBe('http'); + expect(result.config.port).toBe(8080); + } + }); + + it('uses MCP_PORT when --port not set', () => { + const parsed = parseArgs(['--key', 're_x', '--http']); + const result = resolveConfig(parsed, { + RESEND_API_KEY: 're_x', + MCP_PORT: '9000', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.port).toBe(9000); + } + }); + + it('--port overrides MCP_PORT', () => { + const parsed = parseArgs(['--key', 're_x', '--http', '--port', '4000']); + const result = resolveConfig(parsed, { + RESEND_API_KEY: 're_x', + MCP_PORT: '9000', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.port).toBe(4000); + } + }); + + it('invalid or out-of-range port falls back to default', () => { + const invalid = resolveConfig( + parseArgs(['--key', 're_x', '--http', '--port', 'not-a-number']), + { RESEND_API_KEY: 're_x' }, + ); + const outOfRange = resolveConfig(parseArgs(['--key', 're_x', '--http']), { + RESEND_API_KEY: 're_x', + MCP_PORT: '99999', + }); + expect(invalid.ok && outOfRange.ok).toBe(true); + if (invalid.ok) expect(invalid.config.port).toBe(3000); + if (outOfRange.ok) expect(outOfRange.config.port).toBe(3000); + }); +}); diff --git a/tests/cli/resolveConfigOrExit.test.ts b/tests/cli/resolveConfigOrExit.test.ts new file mode 100644 index 0000000..93d627c --- /dev/null +++ b/tests/cli/resolveConfigOrExit.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveConfigOrExit } from '../../src/cli/index.js'; +import { parseArgs } from '../../src/cli/parse.js'; + +describe('resolveConfigOrExit', () => { + let exitSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null) => { + throw new Error(`process.exit(${code})`); + }) as ReturnType; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('calls process.exit(0) and prints help when --help', () => { + expect(() => resolveConfigOrExit(parseArgs(['--help']), {})).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('RESEND_API_KEY'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Usage'), + ); + }); + + it('calls process.exit(0) when -h', () => { + expect(() => resolveConfigOrExit(parseArgs(['-h']), {})).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('calls process.exit(1) and console.error when config invalid', () => { + const parsed = parseArgs([]); + expect(() => resolveConfigOrExit(parsed, {})).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error:', + expect.stringContaining('API key'), + ); + }); + + it('returns config when valid', () => { + const parsed = parseArgs(['--key', 're_abc', '--sender', 'x@r.dev']); + const config = resolveConfigOrExit(parsed, {}); + expect(config).toEqual({ + apiKey: 're_abc', + senderEmailAddress: 'x@r.dev', + replierEmailAddresses: [], + transport: 'stdio', + port: 3000, + }); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('returns config with transport http and port when --http', () => { + const parsed = parseArgs(['--key', 're_abc', '--http', '--port', '8080']); + const config = resolveConfigOrExit(parsed, {}); + expect(config.transport).toBe('http'); + expect(config.port).toBe(8080); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..c8c21be --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,24 @@ +import type { Resend } from 'resend'; +import { describe, expect, it } from 'vitest'; +import { createMcpServer } from '../src/server.js'; + +describe('createMcpServer', () => { + it('returns an MCP server with connect method', () => { + const resend = {} as Resend; + const server = createMcpServer(resend, { + senderEmailAddress: 'from@test.dev', + replierEmailAddresses: ['reply@test.dev'], + }); + expect(server).toBeDefined(); + expect(typeof server.connect).toBe('function'); + }); + + it('accepts empty sender and repliers', () => { + const resend = {} as Resend; + const server = createMcpServer(resend, { + replierEmailAddresses: [], + }); + expect(server).toBeDefined(); + expect(typeof server.connect).toBe('function'); + }); +}); diff --git a/tests/transports/http.test.ts b/tests/transports/http.test.ts new file mode 100644 index 0000000..e04f2af --- /dev/null +++ b/tests/transports/http.test.ts @@ -0,0 +1,26 @@ +import type { Resend } from 'resend'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runHttp } from '../../src/transports/http.js'; + +vi.mock('../../src/server.js', () => ({ + createMcpServer: vi.fn(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + })), +})); + +describe('runHttp', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts server and resolves when listening', async () => { + const resend = {} as Resend; + await expect( + runHttp(resend, { replierEmailAddresses: [] }, 0), + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/transports/stdio.test.ts b/tests/transports/stdio.test.ts new file mode 100644 index 0000000..eb7d906 --- /dev/null +++ b/tests/transports/stdio.test.ts @@ -0,0 +1,58 @@ +import type { Resend } from 'resend'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runStdio } from '../../src/transports/stdio.js'; + +const mockConnect = vi.fn().mockResolvedValue(undefined); + +vi.mock('../../src/server.js', () => ({ + createMcpServer: vi.fn(() => ({ + connect: mockConnect, + })), +})); + +vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: vi.fn(() => ({})), +})); + +describe('runStdio', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates server and connects transport', async () => { + const resend = {} as Resend; + await runStdio(resend, { replierEmailAddresses: [] }); + const { createMcpServer } = await import('../../src/server.js'); + expect(createMcpServer).toHaveBeenCalledWith(resend, { + senderEmailAddress: undefined, + replierEmailAddresses: [], + }); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('passes sender and repliers to server', async () => { + const resend = {} as Resend; + await runStdio(resend, { + senderEmailAddress: 'x@r.dev', + replierEmailAddresses: ['a@x.com', 'b@x.com'], + }); + const { createMcpServer } = await import('../../src/server.js'); + expect(createMcpServer).toHaveBeenCalledWith(resend, { + senderEmailAddress: 'x@r.dev', + replierEmailAddresses: ['a@x.com', 'b@x.com'], + }); + }); + + it('rejects when server.connect rejects', async () => { + mockConnect.mockRejectedValueOnce(new Error('connect failed')); + const resend = {} as Resend; + await expect( + runStdio(resend, { replierEmailAddresses: [] }), + ).rejects.toThrow('connect failed'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 78170f4..61bce99 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,12 +6,12 @@ "resolveJsonModule": true, "jsx": "react-jsx", "outDir": "./dist", - "rootDir": ".", + "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["./**/*"], - "exclude": ["node_modules"] + "include": ["src/**/*"], + "exclude": ["node_modules", "build"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8363e16 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +});