From 133ce5b49c7973356274851825161e81d78aba6a Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Thu, 12 Feb 2026 17:44:02 +0530 Subject: [PATCH] feat: add HTTP server support and compose email tool - Introduced an HTTP server mode for the MCP server, allowing it to run on localhost for testing and integration. - Added a new `compose_email` tool for interactive email composition. - Updated README with instructions for running the server and using the new tool. - Enhanced package.json with new scripts for development and starting the server. - Updated dependencies including express and @mcp-ui/server. --- README.md | 52 +++++ index.ts | 18 +- package.json | 5 + pnpm-lock.yaml | 264 +++++++++++++++++++++++ server/http.ts | 94 ++++++++ tools/composeEmailUi.ts | 468 ++++++++++++++++++++++++++++++++++++++++ tools/index.ts | 1 + 7 files changed, 898 insertions(+), 4 deletions(-) create mode 100644 server/http.ts create mode 100644 tools/composeEmailUi.ts diff --git a/README.md b/README.md index 51a70ec..b688fed 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,58 @@ Close and reopen Claude Desktop. Verify that the `resend` tool is available in t Chat with Claude and tell it to send you an email using the `resend` tool. +### Running on Localhost (HTTP Server) + +You can run the MCP server as an HTTP server on localhost for testing or integration with web applications. + +1. **Build the project** (if you haven't already): + + ```bash + pnpm install + pnpm run build + ``` + +2. **Set your API key**: + + ```bash + export RESEND_API_KEY=re_your_key_here + ``` + +3. **Start the HTTP server**: + + ```bash + pnpm run dev + ``` + + Or with a custom port: + + ```bash + node build/index.js --http --port 8080 --key=re_your_key_here + ``` + +4. **Access the server**: + + - **Health check**: http://localhost:3000/health + - **MCP endpoint**: http://localhost:3000/mcp + +The HTTP server supports: +- **POST /mcp**: Send MCP requests (JSON-RPC) +- **GET /mcp**: Server-Sent Events (SSE) streaming for long-running operations +- **GET /sse**: SSE endpoint for ChatGPT compatibility +- **GET /health**: Health check endpoint + +**ChatGPT Custom Apps:** +- Use `http://localhost:3000/mcp` or `http://localhost:3000/sse` as the server URL +- The server supports both MCP Apps and ChatGPT Apps SDK protocols +- Interactive email composition UI is available via the `compose_email` tool + +**Command-line options for HTTP mode:** +- `--http` or `--server`: Enable HTTP server mode +- `--port` or `-p`: Set the port (default: 3000) +- `--key`: Your Resend API key (or use `RESEND_API_KEY` environment variable) +- `--sender`: Sender email address (optional) +- `--reply-to`: Reply-to email address (optional) + ### Testing with MCP Inspector > **Note:** Make sure you've built the project first (see [Setup](#setup) section above). diff --git a/index.ts b/index.ts index 1d97342..45ec106 100644 --- a/index.ts +++ b/index.ts @@ -3,9 +3,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import minimist from 'minimist'; import { Resend } from 'resend'; import packageJson from './package.json' with { type: 'json' }; +import { startHttpServer } from './server/http.js'; import { addApiKeyTools, addBroadcastTools, + addComposeEmailTool, addContactTools, addDomainTools, addEmailTools, @@ -44,7 +46,6 @@ if (!apiKey) { const resend = new Resend(apiKey); -// Create server instance const server = new McpServer({ name: 'email-sending-service', version: packageJson.version, @@ -58,14 +59,23 @@ addBroadcastTools(server, resend, { addContactTools(server, resend); addDomainTools(server, resend); addEmailTools(server, resend, { senderEmailAddress, replierEmailAddresses }); +addComposeEmailTool(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('Email sending service MCP Server running on stdio'); + if (argv.http || argv.server) { + const port = argv.port || argv.p || process.env.PORT || 3000; + await startHttpServer(server, port); + } else { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Email sending service MCP Server running on stdio'); + } } main().catch((error) => { diff --git a/package.json b/package.json index 1e6b545..02c116e 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,16 @@ "type": "module", "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", + "dev": "node build/index.js --http --port 3000", + "start": "node build/index.js", "inspector": "npx @modelcontextprotocol/inspector@latest", "lint": "biome check .", "lint:fix": "biome check . --write" }, "dependencies": { + "@mcp-ui/server": "^6.0.1", "@modelcontextprotocol/sdk": "1.25.2", + "express": "^5.2.1", "minimist": "1.2.8", "resend": "6.9.1", "zod": "4.3.6" @@ -21,6 +25,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.10", + "@types/express": "^5.0.0", "@types/minimist": "1.2.5", "@types/node": "25.0.3", "typescript": "5.9.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b57b4..59620e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@mcp-ui/server': + specifier: ^6.0.1 + version: 6.0.1(hono@4.11.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) '@modelcontextprotocol/sdk': specifier: 1.25.2 version: 1.25.2(hono@4.11.3)(zod@4.3.6) + express: + specifier: ^5.2.1 + version: 5.2.1 minimist: specifier: 1.2.8 version: 1.2.8 @@ -24,6 +30,9 @@ importers: '@biomejs/biome': specifier: 2.3.10 version: 2.3.10 + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 '@types/minimist': specifier: 1.2.5 version: 1.2.5 @@ -99,6 +108,22 @@ packages: peerDependencies: hono: ^4 + '@mcp-ui/server@6.0.1': + resolution: {integrity: sha512-YIm9l+idCllSvmAfpulfd27qDAtexNIYls5XwqHKytxsxO1NcsVtkBWuaOg8M2alMfRJ6zLSmFHGmit3ZFuVPQ==} + + '@modelcontextprotocol/ext-apps@0.3.1': + resolution: {integrity: sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.24.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@modelcontextprotocol/sdk@1.25.2': resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==} engines: {node: '>=18'} @@ -109,6 +134,61 @@ packages: '@cfworker/json-schema': optional: true + '@oven/bun-darwin-aarch64@1.3.9': + resolution: {integrity: sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw==} + cpu: [arm64] + os: [darwin] + + '@oven/bun-darwin-x64-baseline@1.3.9': + resolution: {integrity: sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg==} + cpu: [x64] + os: [darwin] + + '@oven/bun-darwin-x64@1.3.9': + resolution: {integrity: sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA==} + cpu: [x64] + os: [darwin] + + '@oven/bun-linux-aarch64-musl@1.3.9': + resolution: {integrity: sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ==} + cpu: [arm64] + os: [linux] + + '@oven/bun-linux-aarch64@1.3.9': + resolution: {integrity: sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g==} + cpu: [arm64] + os: [linux] + + '@oven/bun-linux-x64-baseline@1.3.9': + resolution: {integrity: sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64-musl-baseline@1.3.9': + resolution: {integrity: sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64-musl@1.3.9': + resolution: {integrity: sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64@1.3.9': + resolution: {integrity: sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg==} + cpu: [x64] + os: [linux] + + '@oven/bun-windows-x64-baseline@1.3.9': + resolution: {integrity: sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g==} + cpu: [x64] + os: [win32] + + '@oven/bun-windows-x64@1.3.9': + resolution: {integrity: sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q==} + cpu: [x64] + os: [win32] + '@react-email/render@1.1.2': resolution: {integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==} engines: {node: '>=18.0.0'} @@ -116,18 +196,77 @@ packages: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + '@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-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + 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-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + 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/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@zone-eu/mailsplit@5.4.8': resolution: {integrity: sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==} @@ -660,6 +799,43 @@ snapshots: dependencies: hono: 4.11.3 + '@mcp-ui/server@6.0.1(hono@4.11.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/ext-apps': 0.3.1(@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.25.2(hono@4.11.3)(zod@4.3.6) + transitivePeerDependencies: + - '@cfworker/json-schema' + - hono + - react + - react-dom + - supports-color + - zod + + '@modelcontextprotocol/ext-apps@0.3.1(@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/sdk': 1.25.2(hono@4.11.3)(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + '@oven/bun-darwin-aarch64': 1.3.9 + '@oven/bun-darwin-x64': 1.3.9 + '@oven/bun-darwin-x64-baseline': 1.3.9 + '@oven/bun-linux-aarch64': 1.3.9 + '@oven/bun-linux-aarch64-musl': 1.3.9 + '@oven/bun-linux-x64': 1.3.9 + '@oven/bun-linux-x64-baseline': 1.3.9 + '@oven/bun-linux-x64-musl': 1.3.9 + '@oven/bun-linux-x64-musl-baseline': 1.3.9 + '@oven/bun-windows-x64': 1.3.9 + '@oven/bun-windows-x64-baseline': 1.3.9 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.7(hono@4.11.3) @@ -682,6 +858,39 @@ snapshots: - hono - supports-color + '@oven/bun-darwin-aarch64@1.3.9': + optional: true + + '@oven/bun-darwin-x64-baseline@1.3.9': + optional: true + + '@oven/bun-darwin-x64@1.3.9': + optional: true + + '@oven/bun-linux-aarch64-musl@1.3.9': + optional: true + + '@oven/bun-linux-aarch64@1.3.9': + optional: true + + '@oven/bun-linux-x64-baseline@1.3.9': + optional: true + + '@oven/bun-linux-x64-musl-baseline@1.3.9': + optional: true + + '@oven/bun-linux-x64-musl@1.3.9': + optional: true + + '@oven/bun-linux-x64@1.3.9': + optional: true + + '@oven/bun-windows-x64-baseline@1.3.9': + optional: true + + '@oven/bun-windows-x64@1.3.9': + optional: true + '@react-email/render@1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: html-to-text: 9.0.5 @@ -691,6 +900,24 @@ snapshots: react-promise-suspense: 0.3.4 optional: true + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@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 @@ -698,12 +925,49 @@ snapshots: '@stablelib/base64@1.0.1': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.0.3 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.0.3 + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.0.3 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/minimist@1.2.5': {} '@types/node@25.0.3': dependencies: undici-types: 7.16.0 + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.0.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.0.3 + '@zone-eu/mailsplit@5.4.8': dependencies: libbase64: 1.3.0 diff --git a/server/http.ts b/server/http.ts new file mode 100644 index 0000000..1f39d8d --- /dev/null +++ b/server/http.ts @@ -0,0 +1,94 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; +import packageJson from '../package.json' with { type: 'json' }; + +export async function startHttpServer( + server: McpServer, + port: number, +): Promise { + const app = express(); + + app.use(express.json()); + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, mcp-session-id, Mcp-Session-Id', + ); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + } else { + next(); + } + }); + + let transport: StreamableHTTPServerTransport | null = null; + let isServerConnected = false; + + const initializeTransport = async () => { + if (!transport) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + if (!isServerConnected) { + await server.connect(transport); + isServerConnected = true; + } + } + return transport; + }; + + app.get('/health', (_req, res) => { + res.json({ + status: 'ok', + service: 'email-sending-service', + version: packageJson.version, + }); + }); + + app.all('/mcp', async (req, res) => { + try { + const currentTransport = await initializeTransport(); + const parsedBody = req.method === 'POST' ? req.body : undefined; + await currentTransport.handleRequest(req, res, parsedBody); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + id: req.body?.id || null, + error: { + code: -32603, + message: 'Internal error', + }, + }); + } + } + }); + + app.get('/sse', async (req, res) => { + try { + const currentTransport = await initializeTransport(); + await currentTransport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE request:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'SSE connection failed' }); + } + } + }); + + app.listen(port, () => { + console.error( + `Email sending service MCP Server running on http://localhost:${port}`, + ); + console.error(`Health check: http://localhost:${port}/health`); + console.error(`MCP endpoint: http://localhost:${port}/mcp`); + console.error(`SSE endpoint: http://localhost:${port}/sse`); + console.error( + `Use --http flag to enable HTTP mode, or --port to change port (default: 3000)`, + ); + }); +} diff --git a/tools/composeEmailUi.ts b/tools/composeEmailUi.ts new file mode 100644 index 0000000..f5330e6 --- /dev/null +++ b/tools/composeEmailUi.ts @@ -0,0 +1,468 @@ +import { createUIResource } from '@mcp-ui/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Resend } from 'resend'; +import { z } from 'zod'; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function buildComposerHtml(prefill: { + to: string; + subject: string; + text: string; + cc: string; + bcc: string; + from: string; + replyTo: string; + showFrom: boolean; + showReplyTo: boolean; +}): string { + const { to, subject, text, cc, bcc, from, replyTo, showFrom, showReplyTo } = + prefill; + return ` + + + + + Resend – Send email + + + +
+
+ +
+
+
+ + +
Comma-separated email addresses
+
+ ${showFrom ? `
` : ''} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${showReplyTo ? `
` : ''} + +
+
+ + +`; +} + +export function addComposeEmailTool( + server: McpServer, + _resend: Resend, + options: { + senderEmailAddress?: string; + replierEmailAddresses: string[]; + }, +) { + const { senderEmailAddress, replierEmailAddresses } = options; + const showFrom = !senderEmailAddress; + const showReplyTo = replierEmailAddresses.length === 0; + + const MCP_APPS_TEMPLATE_URI = 'ui://resend/email-composer-mcp-apps'; + const APPS_SDK_TEMPLATE_URI = 'ui://resend/email-composer-apps-sdk'; + + const buildDefaultHtml = () => + buildComposerHtml({ + to: '', + subject: '', + text: '', + cc: '', + bcc: '', + from: '', + replyTo: Array.isArray(replierEmailAddresses) + ? replierEmailAddresses.join(', ') + : '', + showFrom, + showReplyTo, + }); + + const mcpAppsTemplate = createUIResource({ + uri: MCP_APPS_TEMPLATE_URI, + encoding: 'text', + adapters: { + mcpApps: { + enabled: true, + }, + }, + content: { + type: 'rawHtml', + htmlString: buildDefaultHtml(), + }, + }); + + server.registerResource( + 'email-composer-mcp-apps', + MCP_APPS_TEMPLATE_URI, + { + description: 'Email composer UI template for MCP Apps hosts', + }, + async () => { + const resource = mcpAppsTemplate.resource; + return { + contents: [ + { + uri: MCP_APPS_TEMPLATE_URI, + mimeType: resource.mimeType, + text: resource.text || '', + }, + ], + }; + }, + ); + + const appsSdkTemplate = createUIResource({ + uri: APPS_SDK_TEMPLATE_URI, + encoding: 'text', + adapters: { + appsSdk: { + enabled: true, + config: { intentHandling: 'prompt' }, + }, + }, + content: { + type: 'rawHtml', + htmlString: buildDefaultHtml(), + }, + metadata: { + 'openai/widgetDescription': 'Interactive email composition form', + 'openai/widgetPrefersBorder': true, + 'openai/widgetAccessible': true, + }, + }); + + server.registerResource( + 'email-composer-apps-sdk', + APPS_SDK_TEMPLATE_URI, + { + description: 'Email composer UI template for ChatGPT Apps SDK', + }, + async () => { + const resource = appsSdkTemplate.resource; + return { + contents: [ + { + uri: APPS_SDK_TEMPLATE_URI, + mimeType: resource.mimeType, + text: resource.text || '', + }, + ], + }; + }, + ); + + server.registerTool( + 'compose_email', + { + title: 'Compose Email (UI)', + description: `**Purpose:** Open an interactive email form in the chat so the user can fill or edit fields and send in one step. Use this when the user wants to send an email but some fields are missing or should be edited visually. + +**When to use:** +- User says "send an email" without giving all details (to, subject, body) +- You have partial data (e.g. recipient and subject) and want the user to complete the rest in a form +- User prefers filling a form over answering multiple chat messages + +**Workflow:** Call this tool with any known fields (to, subject, text, cc, bcc). A form appears with those pre-filled; the user fills the rest and clicks Send. The host will then call send-email with the completed data. + +**Pass whatever you already know;** leave other params empty so the user can fill them in the form.`, + inputSchema: { + to: z + .array(z.string()) + .optional() + .describe( + 'Recipient email address(es). Pre-fill in the form; leave empty if unknown.', + ), + subject: z.string().optional().describe('Subject line to pre-fill'), + text: z.string().optional().describe('Plain text body to pre-fill'), + cc: z.array(z.string()).optional().describe('CC addresses to pre-fill'), + bcc: z + .array(z.string()) + .optional() + .describe('BCC addresses to pre-fill'), + }, + _meta: { + ui: { + resourceUri: MCP_APPS_TEMPLATE_URI, + }, + 'openai/outputTemplate': APPS_SDK_TEMPLATE_URI, + 'openai/toolInvocation/invoking': 'Preparing email form...', + 'openai/toolInvocation/invoked': 'Email form ready', + 'openai/widgetAccessible': true, + }, + }, + async ({ to = [], subject = '', text = '', cc = [], bcc = [] }) => { + const toStr = Array.isArray(to) ? to.join(', ') : String(to ?? ''); + const subjectStr = String(subject ?? ''); + const textStr = String(text ?? ''); + const ccStr = Array.isArray(cc) ? cc.join(', ') : String(cc ?? ''); + const bccStr = Array.isArray(bcc) ? bcc.join(', ') : String(bcc ?? ''); + + const html = buildComposerHtml({ + to: toStr, + subject: subjectStr, + text: textStr, + cc: ccStr, + bcc: bccStr, + from: '', + replyTo: Array.isArray(replierEmailAddresses) + ? replierEmailAddresses.join(', ') + : '', + showFrom, + showReplyTo, + }); + + const uiResource = createUIResource({ + uri: `ui://resend/email-composer/${Date.now()}`, + encoding: 'text', + adapters: { + appsSdk: { + enabled: true, + config: { intentHandling: 'prompt' }, + }, + }, + content: { + type: 'rawHtml', + htmlString: html, + }, + metadata: { + 'openai/widgetDescription': 'Interactive email composition form', + 'openai/widgetPrefersBorder': true, + 'openai/widgetAccessible': true, + }, + }); + + return { + content: [ + { + type: 'text', + text: 'Fill in any missing fields below and click **Send email** to send.', + }, + uiResource, + ], + }; + }, + ); +} diff --git a/tools/index.ts b/tools/index.ts index 7ac7793..0d86035 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -1,5 +1,6 @@ export * from './apiKeys.js'; export * from './broadcasts.js'; +export * from './composeEmailUi.js'; export * from './contacts.js'; export * from './domains.js'; export * from './emails.js';