From 24eb857df2b3d5da6aadbdacb2d98183559c6a1f Mon Sep 17 00:00:00 2001 From: jolestar Date: Tue, 21 Oct 2025 21:30:11 +0800 Subject: [PATCH] Support cors config --- README.md | 149 +++++++++++++++++++++- src/index.ts | 1 + src/startHTTPServer.test.ts | 248 ++++++++++++++++++++++++++++++++++++ src/startHTTPServer.ts | 114 +++++++++++++++-- 4 files changed, 498 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1e55bcc..834411a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A TypeScript streamable HTTP and SSE proxy for [MCP](https://modelcontextprotocol.io/) servers that use `stdio` transport. > [!NOTE] -> CORS is enabled by default. +> CORS is enabled by default with configurable options. See [CORS Configuration](#cors-configuration) for details. > [!NOTE] > For a Python implementation, see [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy). @@ -143,6 +143,152 @@ The following endpoints do not require authentication: - **Generate strong keys**: Use cryptographically secure random strings for API keys - **Rotate keys regularly**: Change API keys periodically for better security +### CORS Configuration + +MCP Proxy provides flexible CORS (Cross-Origin Resource Sharing) configuration to control how browsers can access your MCP server from different origins. + +#### Default Behavior + +By default, CORS is enabled with the following settings: +- **Origin**: `*` (allow all origins) +- **Methods**: `GET, POST, OPTIONS` +- **Headers**: `Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-Id` +- **Credentials**: `true` +- **Exposed Headers**: `Mcp-Session-Id` + +#### Basic Configuration + +```typescript +import { startHTTPServer } from 'mcp-proxy'; + +// Use default CORS settings (backward compatible) +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, +}); + +// Explicitly enable default CORS +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: true, +}); + +// Disable CORS completely +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: false, +}); +``` + +#### Advanced CORS Configuration + +For more control over CORS behavior, you can provide a detailed configuration: + +```typescript +import { startHTTPServer, CorsOptions } from 'mcp-proxy'; + +const corsOptions: CorsOptions = { + // Allow specific origins + origin: ['https://app.example.com', 'https://admin.example.com'], + + // Or use a function for dynamic origin validation + origin: (origin: string) => origin.endsWith('.example.com'), + + // Specify allowed methods + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + + // Allow any headers (useful for browser clients with custom headers) + allowedHeaders: '*', + + // Or specify exact headers + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'Accept', + 'Mcp-Session-Id', + 'Last-Event-Id', + 'X-Custom-Header', + 'X-API-Key' + ], + + // Headers to expose to the client + exposedHeaders: ['Mcp-Session-Id', 'X-Total-Count'], + + // Allow credentials + credentials: true, + + // Cache preflight requests for 24 hours + maxAge: 86400, +}; + +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: corsOptions, +}); +``` + +#### Common Use Cases + +**Allow any custom headers (solves browser CORS issues):** +```typescript +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: { + allowedHeaders: '*', // Allows X-Custom-Header, X-API-Key, etc. + }, +}); +``` + +**Restrict to specific domains:** +```typescript +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: { + origin: ['https://myapp.com', 'https://admin.myapp.com'], + allowedHeaders: '*', + }, +}); +``` + +**Development-friendly settings:** +```typescript +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: { + origin: ['http://localhost:3000', 'http://localhost:5173'], // Common dev ports + allowedHeaders: '*', + credentials: true, + }, +}); +``` + +#### Migration from Older Versions + +If you were using mcp-proxy 5.5.6 and want the same permissive behavior in 5.9.0+: + +```typescript +// Old behavior (5.5.6) - automatic wildcard headers +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, +}); + +// New equivalent (5.9.0+) - explicit wildcard headers +await startHTTPServer({ + createServer: async () => { /* ... */ }, + port: 3000, + cors: { + allowedHeaders: '*', + }, +}); +``` + ### Node.js SDK The Node.js SDK provides several utilities that are used to create a proxy. @@ -198,6 +344,7 @@ Options: - `streamEndpoint`: Streamable HTTP endpoint path (default: "/mcp", set to null to disable) - `stateless`: Enable stateless mode for HTTP streamable transport (default: false) - `apiKey`: API key for authenticating requests (optional) +- `cors`: CORS configuration (default: enabled with permissive settings, see CORS Configuration section) - `onConnect`: Callback when a server connects (optional) - `onClose`: Callback when a server disconnects (optional) - `onUnhandledRequest`: Callback for unhandled HTTP requests (optional) diff --git a/src/index.ts b/src/index.ts index 96e5f3c..b7bc5df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export type { AuthConfig } from "./authentication.js"; export { AuthenticationMiddleware } from "./authentication.js"; export { InMemoryEventStore } from "./InMemoryEventStore.js"; export { proxyServer } from "./proxyServer.js"; +export type { CorsOptions } from "./startHTTPServer.js"; export { startHTTPServer } from "./startHTTPServer.js"; export { ServerType, startStdioServer } from "./startStdioServer.js"; export { tapTransport } from "./tapTransport.js"; diff --git a/src/startHTTPServer.test.ts b/src/startHTTPServer.test.ts index 2223fec..b4965e3 100644 --- a/src/startHTTPServer.test.ts +++ b/src/startHTTPServer.test.ts @@ -1675,3 +1675,251 @@ it("succeeds when authenticate returns { authenticated: true } in stateless mode await httpServer.close(); await stdioClient.close(); }); + +// CORS Configuration Tests + +it("supports wildcard CORS headers", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: { + allowedHeaders: "*", + }, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test OPTIONS request to verify CORS headers + const response = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://example.com", + }, + method: "OPTIONS", + }); + + expect(response.status).toBe(204); + + // Verify wildcard is used for allowed headers + const allowedHeaders = response.headers.get("Access-Control-Allow-Headers"); + expect(allowedHeaders).toBe("*"); + + await httpServer.close(); +}); + +it("supports custom CORS headers array", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: { + allowedHeaders: ["Content-Type", "X-Custom-Header", "X-API-Key"], + }, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test OPTIONS request to verify CORS headers + const response = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://example.com", + }, + method: "OPTIONS", + }); + + expect(response.status).toBe(204); + + // Verify custom headers are used + const allowedHeaders = response.headers.get("Access-Control-Allow-Headers"); + expect(allowedHeaders).toBe("Content-Type, X-Custom-Header, X-API-Key"); + + await httpServer.close(); +}); + +it("supports origin validation with array", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: { + origin: ["https://app.example.com", "https://admin.example.com"], + }, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test with allowed origin + const response1 = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://app.example.com", + }, + method: "OPTIONS", + }); + + expect(response1.status).toBe(204); + expect(response1.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example.com"); + + // Test with disallowed origin + const response2 = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://malicious.com", + }, + method: "OPTIONS", + }); + + expect(response2.status).toBe(204); + expect(response2.headers.get("Access-Control-Allow-Origin")).toBeNull(); + + await httpServer.close(); +}); + +it("supports origin validation with function", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: { + origin: (origin: string) => origin.endsWith(".example.com"), + }, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test with allowed origin + const response1 = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://subdomain.example.com", + }, + method: "OPTIONS", + }); + + expect(response1.status).toBe(204); + expect(response1.headers.get("Access-Control-Allow-Origin")).toBe("https://subdomain.example.com"); + + // Test with disallowed origin + const response2 = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://malicious.com", + }, + method: "OPTIONS", + }); + + expect(response2.status).toBe(204); + expect(response2.headers.get("Access-Control-Allow-Origin")).toBeNull(); + + await httpServer.close(); +}); + +it("disables CORS when cors: false", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: false, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test OPTIONS request - should not have CORS headers + const response = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://example.com", + }, + method: "OPTIONS", + }); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.headers.get("Access-Control-Allow-Headers")).toBeNull(); + + await httpServer.close(); +}); + +it("uses default CORS settings when cors: true", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: true, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test OPTIONS request to verify default CORS headers + const response = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://example.com", + }, + method: "OPTIONS", + }); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-Id"); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + + await httpServer.close(); +}); + +it("supports custom methods and maxAge", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + cors: { + maxAge: 86400, + methods: ["GET", "POST", "PUT", "DELETE"], + }, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test OPTIONS request to verify custom settings + const response = await fetch(`http://localhost:${port}/mcp`, { + headers: { + Origin: "https://example.com", + }, + method: "OPTIONS", + }); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE"); + expect(response.headers.get("Access-Control-Max-Age")).toBe("86400"); + + await httpServer.close(); +}); diff --git a/src/startHTTPServer.ts b/src/startHTTPServer.ts index 2e216c0..dd74f5c 100644 --- a/src/startHTTPServer.ts +++ b/src/startHTTPServer.ts @@ -11,6 +11,15 @@ import { randomUUID } from "node:crypto"; import { AuthConfig, AuthenticationMiddleware } from "./authentication.js"; import { InMemoryEventStore } from "./InMemoryEventStore.js"; +export interface CorsOptions { + allowedHeaders?: string | string[]; // Allow string[] or '*' for wildcard + credentials?: boolean; + exposedHeaders?: string[]; + maxAge?: number; + methods?: string[]; + origin?: ((origin: string) => boolean) | string | string[]; +} + export type SSEServer = { close: () => Promise; }; @@ -102,6 +111,94 @@ const cleanupServer = async ( } }; +// Helper function to apply CORS headers +const applyCorsHeaders = ( + req: http.IncomingMessage, + res: http.ServerResponse, + corsOptions?: boolean | CorsOptions, +) => { + if (!req.headers.origin) { + return; + } + + // Default CORS configuration for backward compatibility + const defaultCorsOptions: CorsOptions = { + allowedHeaders: "Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-Id", + credentials: true, + exposedHeaders: ["Mcp-Session-Id"], + methods: ["GET", "POST", "OPTIONS"], + origin: "*", + }; + + let finalCorsOptions: CorsOptions; + + if (corsOptions === false) { + // CORS disabled + return; + } else if (corsOptions === true || corsOptions === undefined) { + // Use default CORS settings + finalCorsOptions = defaultCorsOptions; + } else { + // Merge user options with defaults + finalCorsOptions = { + ...defaultCorsOptions, + ...corsOptions, + }; + } + + try { + const origin = new URL(req.headers.origin); + + // Handle origin + let allowedOrigin = "*"; + if (finalCorsOptions.origin) { + if (typeof finalCorsOptions.origin === "string") { + allowedOrigin = finalCorsOptions.origin; + } else if (Array.isArray(finalCorsOptions.origin)) { + allowedOrigin = finalCorsOptions.origin.includes(origin.origin) + ? origin.origin + : "false"; + } else if (typeof finalCorsOptions.origin === "function") { + allowedOrigin = finalCorsOptions.origin(origin.origin) ? origin.origin : "false"; + } + } + + if (allowedOrigin !== "false") { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + } + + // Handle credentials + if (finalCorsOptions.credentials !== undefined) { + res.setHeader("Access-Control-Allow-Credentials", finalCorsOptions.credentials.toString()); + } + + // Handle methods + if (finalCorsOptions.methods) { + res.setHeader("Access-Control-Allow-Methods", finalCorsOptions.methods.join(", ")); + } + + // Handle allowed headers + if (finalCorsOptions.allowedHeaders) { + const allowedHeaders = typeof finalCorsOptions.allowedHeaders === "string" + ? finalCorsOptions.allowedHeaders + : finalCorsOptions.allowedHeaders.join(", "); + res.setHeader("Access-Control-Allow-Headers", allowedHeaders); + } + + // Handle exposed headers + if (finalCorsOptions.exposedHeaders) { + res.setHeader("Access-Control-Expose-Headers", finalCorsOptions.exposedHeaders.join(", ")); + } + + // Handle max age + if (finalCorsOptions.maxAge !== undefined) { + res.setHeader("Access-Control-Max-Age", finalCorsOptions.maxAge.toString()); + } + } catch (error) { + console.error("[mcp-proxy] error parsing origin", error); + } +}; + const handleStreamRequest = async ({ activeTransports, authenticate, @@ -586,6 +683,7 @@ const handleSSERequest = async ({ export const startHTTPServer = async ({ apiKey, authenticate, + cors, createServer, enableJsonResponse, eventStore, @@ -601,6 +699,7 @@ export const startHTTPServer = async ({ }: { apiKey?: string; authenticate?: (request: http.IncomingMessage) => Promise; + cors?: boolean | CorsOptions; createServer: (request: http.IncomingMessage) => Promise; enableJsonResponse?: boolean; eventStore?: EventStore; @@ -633,19 +732,8 @@ export const startHTTPServer = async ({ * @author https://dev.classmethod.jp/articles/mcp-sse/ */ const httpServer = http.createServer(async (req, res) => { - if (req.headers.origin) { - try { - const origin = new URL(req.headers.origin); - - res.setHeader("Access-Control-Allow-Origin", origin.origin); - res.setHeader("Access-Control-Allow-Credentials", "true"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-Id"); - res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); - } catch (error) { - console.error("[mcp-proxy] error parsing origin", error); - } - } + // Apply CORS headers + applyCorsHeaders(req, res, cors); if (req.method === "OPTIONS") { res.writeHead(204);