Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Open Claude Desktop settings > "Developer" tab > "Edit Config".
### HTTP Transport

Run the server over HTTP for remote or web-based integrations. In HTTP mode, each client authenticates by passing their Resend API key as a Bearer token in the `Authorization` header.
HTTP mode also enforces an `Origin` allowlist for browser requests.

Start the server:

Expand Down Expand Up @@ -110,6 +111,12 @@ You can also set the port via the `MCP_PORT` environment variable:
MCP_PORT=3000 npx -y resend-mcp --http
```

To customize bind host and Origin allowlist:

```bash
MCP_HOST=0.0.0.0 MCP_ALLOWED_ORIGINS=https://app.example.com npx -y resend-mcp --http --port 3000
```

### Options

You can pass additional arguments to configure the server:
Expand All @@ -119,13 +126,17 @@ You can pass additional arguments to configure the server:
- `--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)
- `--host`: HTTP bind host when using `--http` (default: `127.0.0.1`, or `MCP_HOST`)
- `--origins`: Comma-separated Origin allowlist when using `--http` (default: `http://127.0.0.1:<port>,http://localhost:<port>`, or `MCP_ALLOWED_ORIGINS`)

Environment variables:

- `RESEND_API_KEY`: Your Resend API key (required for stdio, optional for HTTP since clients pass it via Bearer token)
- `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)
- `MCP_HOST`: HTTP bind host when using `--http` (optional)
- `MCP_ALLOWED_ORIGINS`: Comma-separated Origin allowlist for HTTP mode (optional)

> [!NOTE]
> If you don't provide a sender email address, the MCP server will ask you to provide one each time you call the tool.
Expand Down
3 changes: 3 additions & 0 deletions src/cli/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export const CLI_STRING_OPTIONS = [
'sender',
'reply-to',
'port',
'host',
'origins',
] as const;

export const DEFAULT_HTTP_PORT = 3000;
export const DEFAULT_HTTP_HOST = '127.0.0.1';
4 changes: 4 additions & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ Options:
--reply-to <email> Reply-to; repeat for multiple (or REPLY_TO_EMAIL_ADDRESSES)
--http Run HTTP server (Streamable HTTP at /mcp) instead of stdio
--port <number> HTTP port when using --http (default: 3000, or MCP_PORT)
--host <hostname> HTTP bind host when using --http (default: 127.0.0.1, or MCP_HOST)
--origins <list> Comma-separated Origin allowlist for --http (or MCP_ALLOWED_ORIGINS)
-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)
MCP_HOST HTTP bind host when using --http (optional)
MCP_ALLOWED_ORIGINS Comma-separated Origin allowlist for HTTP (optional)
`.trim();

export function printHelp(): void {
Expand Down
39 changes: 37 additions & 2 deletions src/cli/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ParsedArgs } from 'minimist';
import { DEFAULT_HTTP_PORT } from './constants.js';
import { DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT } from './constants.js';
import { parseReplierAddresses } from './parse.js';
import type { ResolveResult } from './types.js';

Expand All @@ -19,6 +19,33 @@ function parsePort(parsed: ParsedArgs, env: NodeJS.ProcessEnv): number {
return DEFAULT_HTTP_PORT;
}

function parseHost(parsed: ParsedArgs, env: NodeJS.ProcessEnv): string {
const fromArg = typeof parsed.host === 'string' ? parsed.host.trim() : '';
if (fromArg) return fromArg;
const fromEnv = typeof env.MCP_HOST === 'string' ? env.MCP_HOST.trim() : '';
if (fromEnv) return fromEnv;
return DEFAULT_HTTP_HOST;
}

function parseAllowedOrigins(
parsed: ParsedArgs,
env: NodeJS.ProcessEnv,
port: number,
): string[] {
const raw =
typeof parsed.origins === 'string'
? parsed.origins
: (env.MCP_ALLOWED_ORIGINS ?? '');
const fromInput = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);

if (fromInput.length > 0) return fromInput;

return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
}

/**
* Resolve config from parsed argv and env. No side effects, no exit.
*/
Expand Down Expand Up @@ -50,6 +77,8 @@ export function resolveConfig(
: undefined);

const port = parsePort(parsed, env);
const host = parseHost(parsed, env);
const allowedOrigins = parseAllowedOrigins(parsed, env, port);

const base = {
senderEmailAddress: senderEmailAddress ?? '',
Expand All @@ -60,7 +89,13 @@ export function resolveConfig(
return {
ok: true,
config: http
? { ...base, transport: 'http' as const, apiKey: apiKey?.trim() }
? {
...base,
transport: 'http' as const,
apiKey: apiKey?.trim(),
host,
allowedOrigins,
}
: { ...base, transport: 'stdio' as const, apiKey: apiKey!.trim() },
};
}
2 changes: 2 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface HttpConfig {
replierEmailAddresses: string[];
transport: 'http';
port: number;
host: string;
allowedOrigins: string[];
}

export type CliConfig = StdioConfig | HttpConfig;
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ if (config.transport === 'http') {
// HTTP mode: no Resend client needed at startup. Each connecting client
// provides their own API key via the Authorization: Bearer header,
// and a per-session Resend client is created in the transport layer.
runHttp(serverOptions, config.port).catch(onFatal);
runHttp(serverOptions, config.port, config.host, config.allowedOrigins).catch(
onFatal,
);
} else {
// Stdio mode: single user, API key is required at startup.
const resend = new Resend(config.apiKey);
Expand Down
102 changes: 86 additions & 16 deletions src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,23 @@ const sessions: Record<string, StreamableHTTPServerTransport> = {};
function sendJsonRpcError(
res: ServerResponse,
statusCode: number,
code: number,
message: string,
type?: string,
): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json');
if (statusCode === 401) {
res.setHeader('WWW-Authenticate', 'Bearer realm="resend-mcp"');
}
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message },
error: {
code,
message,
...(type ? { data: { type, status: statusCode } } : {}),
},
id: null,
}),
);
Expand All @@ -31,11 +40,45 @@ function sendJsonRpcError(
*/
function extractBearerToken(req: IncomingMessage): string | null {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return null;
const token = header.slice('Bearer '.length).trim();
if (!header) return null;
const [scheme, ...rest] = header.trim().split(/\s+/);
if (!scheme || scheme.toLowerCase() !== 'bearer' || rest.length === 0)
return null;
const token = rest.join(' ').trim();
return token || null;
}

function normalizeOrigin(origin: string): string | null {
try {
return new URL(origin).origin.toLowerCase();
} catch {
return null;
}
}

function isAllowedOrigin(
req: IncomingMessage,
allowedOrigins: ReadonlySet<string>,
): boolean {
const rawOrigin = req.headers.origin;
if (!rawOrigin) return true;
const normalized = normalizeOrigin(rawOrigin);
if (!normalized) return false;
return allowedOrigins.has(normalized);
}

function setCorsHeaders(req: IncomingMessage, res: ServerResponse): void {
const origin = req.headers.origin;
if (!origin) return;
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, MCP-Session-Id',
);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
}

/**
* Start the HTTP transport. Each session gets its own Resend client created
* from the Bearer token provided by the connecting client. This allows
Expand All @@ -45,8 +88,16 @@ function extractBearerToken(req: IncomingMessage): string | null {
export async function runHttp(
options: ServerOptions,
port: number,
host: string = '127.0.0.1',
allowedOrigins: readonly string[] = [
`http://127.0.0.1:${port}`,
`http://localhost:${port}`,
],
): Promise<Server> {
const app = createMcpExpressApp();
const normalizedAllowedOrigins = new Set(
allowedOrigins.map(normalizeOrigin).filter((o): o is string => o !== null),
);

app.get('/health', (_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
Expand All @@ -56,6 +107,25 @@ export async function runHttp(
app.all(
'/mcp',
async (req: IncomingMessage & { body?: unknown }, res: ServerResponse) => {
if (!isAllowedOrigin(req, normalizedAllowedOrigins)) {
sendJsonRpcError(
res,
403,
-32003,
'Forbidden: origin is not allowed',
'forbidden',
);
return;
}

setCorsHeaders(req, res);

if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}

const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: StreamableHTTPServerTransport | undefined;

Expand All @@ -73,7 +143,9 @@ export async function runHttp(
sendJsonRpcError(
res,
401,
'Unauthorized: provide a Resend API key via Authorization: Bearer <key>',
-32002,
'Unauthorized: provide Authorization: Bearer <resend-api-key>',
'auth_error',
);
return;
}
Expand All @@ -93,18 +165,15 @@ export async function runHttp(
const server = createMcpServer(resend, options);
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,
}),
);
sendJsonRpcError(res, 404, -32001, 'Session not found');
return;
} else {
sendJsonRpcError(res, 400, 'Bad Request: No valid session ID provided');
sendJsonRpcError(
res,
400,
-32000,
'Bad Request: No valid session ID provided',
);
return;
}

Expand All @@ -113,8 +182,9 @@ export async function runHttp(
);

return new Promise((resolve, reject) => {
const server = app.listen(port, () => {
console.error(`Resend MCP server listening on http://127.0.0.1:${port}`);
const server = app.listen(port, host);
server.once('listening', () => {
console.error(`Resend MCP server listening on http://${host}:${port}`);
console.error(' Streamable HTTP: POST/GET/DELETE /mcp');
resolve(server);
});
Expand Down
27 changes: 27 additions & 0 deletions tests/cli/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { parseArgs } from '../../src/cli/parse.js';
import { resolveConfig } from '../../src/cli/resolve.js';
import type { HttpConfig } from '../../src/cli/types.js';

describe('resolveConfig', () => {
it('returns error when no API key in stdio mode', () => {
Expand Down Expand Up @@ -122,6 +123,11 @@ describe('resolveConfig', () => {
if (result.ok) {
expect(result.config.transport).toBe('http');
expect(result.config.port).toBe(3000);
expect((result.config as HttpConfig).host).toBe('127.0.0.1');
expect((result.config as HttpConfig).allowedOrigins).toEqual([
'http://127.0.0.1:3000',
'http://localhost:3000',
]);
}
});

Expand All @@ -132,6 +138,27 @@ describe('resolveConfig', () => {
if (result.ok) {
expect(result.config.transport).toBe('http');
expect(result.config.port).toBe(8080);
expect((result.config as HttpConfig).allowedOrigins).toEqual([
'http://127.0.0.1:8080',
'http://localhost:8080',
]);
}
});

it('uses MCP_HOST and MCP_ALLOWED_ORIGINS in HTTP mode', () => {
const parsed = parseArgs(['--key', 're_x', '--http']);
const result = resolveConfig(parsed, {
RESEND_API_KEY: 're_x',
MCP_HOST: '0.0.0.0',
MCP_ALLOWED_ORIGINS: 'https://app.example.com, https://admin.example.com',
});
expect(result.ok).toBe(true);
if (result.ok) {
expect((result.config as HttpConfig).host).toBe('0.0.0.0');
expect((result.config as HttpConfig).allowedOrigins).toEqual([
'https://app.example.com',
'https://admin.example.com',
]);
}
});

Expand Down
Loading