diff --git a/README.md b/README.md index 67e6421..18dedcb 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: @@ -119,6 +126,8 @@ 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:,http://localhost:`, or `MCP_ALLOWED_ORIGINS`) Environment variables: @@ -126,6 +135,8 @@ Environment variables: - `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. diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 70778fb..b20367c 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -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'; diff --git a/src/cli/help.ts b/src/cli/help.ts index 76fb68e..92bc409 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -12,6 +12,8 @@ Options: --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) + --host HTTP bind host when using --http (default: 127.0.0.1, or MCP_HOST) + --origins Comma-separated Origin allowlist for --http (or MCP_ALLOWED_ORIGINS) -h, --help Show this help Environment: @@ -19,6 +21,8 @@ Environment: 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 { diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index 8dad370..2942278 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -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'; @@ -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. */ @@ -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 ?? '', @@ -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() }, }; } diff --git a/src/cli/types.ts b/src/cli/types.ts index 90c5fd0..ab5fbd4 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -21,6 +21,8 @@ export interface HttpConfig { replierEmailAddresses: string[]; transport: 'http'; port: number; + host: string; + allowedOrigins: string[]; } export type CliConfig = StdioConfig | HttpConfig; diff --git a/src/index.ts b/src/index.ts index ec60ca3..85acbbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/transports/http.ts b/src/transports/http.ts index d083bbb..0fbd402 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -12,14 +12,23 @@ const sessions: Record = {}; 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, }), ); @@ -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, +): 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 @@ -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 { 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' }); @@ -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; @@ -73,7 +143,9 @@ export async function runHttp( sendJsonRpcError( res, 401, - 'Unauthorized: provide a Resend API key via Authorization: Bearer ', + -32002, + 'Unauthorized: provide Authorization: Bearer ', + 'auth_error', ); return; } @@ -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; } @@ -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); }); diff --git a/tests/cli/resolve.test.ts b/tests/cli/resolve.test.ts index 7bb6c31..2fa251c 100644 --- a/tests/cli/resolve.test.ts +++ b/tests/cli/resolve.test.ts @@ -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', () => { @@ -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', + ]); } }); @@ -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', + ]); } }); diff --git a/tests/transports/http.test.ts b/tests/transports/http.test.ts index 617df61..f75872d 100644 --- a/tests/transports/http.test.ts +++ b/tests/transports/http.test.ts @@ -35,4 +35,114 @@ describe('runHttp', () => { server.close(); }); + + it('binds to 127.0.0.1 by default', async () => { + const server = await runHttp({ replierEmailAddresses: [] }, 0); + const address = server.address() as AddressInfo; + expect(address.address).toBe('127.0.0.1'); + server.close(); + }); + + it('returns 401 with consistent auth error shape when missing Bearer token', async () => { + const server = await runHttp({ replierEmailAddresses: [] }, 0); + const { port } = server.address() as AddressInfo; + + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + }), + }); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toContain('Bearer'); + expect(body).toEqual({ + jsonrpc: '2.0', + error: { + code: -32002, + message: 'Unauthorized: provide Authorization: Bearer ', + data: { type: 'auth_error', status: 401 }, + }, + id: null, + }); + + server.close(); + }); + + it('returns 403 for disallowed Origin before auth processing', async () => { + const server = await runHttp( + { replierEmailAddresses: [] }, + 0, + '127.0.0.1', + ['https://allowed.example.com'], + ); + const { port } = server.address() as AddressInfo; + + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://blocked.example.com', + Authorization: 'Bearer re_test', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + }), + }); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body).toEqual({ + jsonrpc: '2.0', + error: { + code: -32003, + message: 'Forbidden: origin is not allowed', + data: { type: 'forbidden', status: 403 }, + }, + id: null, + }); + + server.close(); + }); + + it('handles CORS preflight for allowed Origin', async () => { + const server = await runHttp( + { replierEmailAddresses: [] }, + 0, + '127.0.0.1', + ['https://allowed.example.com'], + ); + const { port } = server.address() as AddressInfo; + + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'OPTIONS', + headers: { + Origin: 'https://allowed.example.com', + 'Access-Control-Request-Method': 'POST', + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'https://allowed.example.com', + ); + + server.close(); + }); });