diff --git a/README.md b/README.md index 2012fcf..3dfbd12 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,58 @@ pnpm run build } ``` +### 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 a09b7f8..1b2816f 100644 --- a/index.ts +++ b/index.ts @@ -4,9 +4,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, addContactPropertyTools, addContactTools, addDomainTools, @@ -46,7 +48,6 @@ if (!apiKey) { const resend = new Resend(apiKey); -// Create server instance const server = new McpServer({ name: 'email-sending-service', version: packageJson.version, @@ -61,14 +62,23 @@ addContactPropertyTools(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 31feb0d..a9603ba 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dist" ], "scripts": { + "dev": "node dist/index.js --http --port 3000", + "start": "node dist/index.js", "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"", "prepare": "npm run build", "inspector": "npx @modelcontextprotocol/inspector@latest", @@ -17,7 +19,9 @@ "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" @@ -27,6 +31,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 1115c4d..dc34831 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 './contactProperties.js'; export * from './contacts.js'; export * from './domains.js';