Implementing CORS Policies for Web-Based MCP Servers

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out MCPcat

The Quick Answer

Enable CORS in your MCP server by configuring appropriate HTTP headers to allow cross-origin requests from browser-based clients:

// Express.js with CORS middleware
app.use(cors({
  origin: ['http://localhost:3000', 'https://your-app.com'],
  methods: ['GET', 'POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id'],
  exposedHeaders: ['Mcp-Session-Id'],
  credentials: true
}));

For Python FastAPI:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["*"],
    expose_headers=["Mcp-Session-Id"]
)

CORS enables browser-based MCP clients to communicate with servers on different origins while preventing unauthorized cross-site requests.

Prerequisites

  • MCP server with HTTP/SSE transport configured
  • Understanding of HTTP headers and same-origin policy
  • Node.js with Express or Python with FastAPI framework

Installation

Install CORS middleware for your framework:

# For Node.js/Express
$npm install cors
# For Python/FastAPI
$pip install fastapi[all]

Configuration

MCP servers using HTTP transport require CORS configuration to accept requests from web browsers. The protocol's bidirectional communication model demands careful header configuration for both standard requests and Server-Sent Events (SSE) streams.

Configure your Express.js server with comprehensive CORS settings:

import cors from 'cors';
import express from 'express';

const app = express();

// Configure CORS for MCP protocol
const corsOptions = {
  origin: function (origin, callback) {
    const allowedOrigins = [
      'http://localhost:3000',
      'https://your-app.com',
      'https://app.your-domain.com'
    ];
    
    // Allow requests with no origin (like mobile apps or Postman)
    if (!origin) return callback(null, true);
    
    if (allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id'],
  exposedHeaders: ['Mcp-Session-Id', 'X-Request-Id'],
  credentials: true,
  maxAge: 86400 // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

The exposedHeaders configuration is critical for MCP servers as protocol-specific headers like Mcp-Session-Id must be accessible to browser clients. Without this, clients cannot maintain session state across requests.

For FastAPI implementations, configure CORS with session support:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# Dynamic origin validation for production
allowed_origins = [
    "http://localhost:3000",
    "https://your-app.com"
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "Mcp-Session-Id"],
    expose_headers=["Mcp-Session-Id", "X-Request-Id"],
    max_age=3600
)

Usage

Handling SSE Connections

Server-Sent Events require special CORS handling due to their persistent connection nature:

// SSE endpoint with CORS headers
app.get('/sse', (req, res) => {
  // Set SSE-specific headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': req.headers.origin || '*',
    'Access-Control-Allow-Credentials': 'true'
  });
  
  // Send events
  res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
});

MCP's SSE transport maintains long-lived connections that browsers handle differently than standard HTTP requests. The Cache-Control: no-cache and Connection: keep-alive headers ensure the stream remains open for bidirectional communication.

Dynamic Origin Validation

Implement dynamic origin validation for flexible deployment scenarios:

function validateOrigin(origin) {
  // Check against environment-specific allowed origins
  const allowedPatterns = [
    /^https:\/\/.*\.your-domain\.com$/,
    /^http:\/\/localhost:\d+$/
  ];
  
  return allowedPatterns.some(pattern => pattern.test(origin));
}

app.use((req, res, next) => {
  const origin = req.headers.origin;
  
  if (origin && validateOrigin(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin'); // Important for caching
  }
  
  next();
});

The Vary: Origin header prevents CDNs and proxies from serving cached responses with incorrect CORS headers to different origins.

Common Issues

Error: "Access to fetch blocked by CORS policy"

This error occurs when the server doesn't include proper CORS headers. The browser blocks the request before it reaches your application code, making it appear as a network error.

// Debugging CORS issues - log all requests
app.use((req, res, next) => {
  console.log(`${req.method} ${req.path} from origin: ${req.headers.origin}`);
  next();
});

// Ensure OPTIONS requests are handled
app.options('*', cors(corsOptions));

Root cause: Browsers send preflight OPTIONS requests for non-simple requests (those with custom headers or non-GET/POST methods). If your server doesn't respond correctly to OPTIONS, the actual request never executes. Always configure your server to handle OPTIONS requests explicitly.

Error: "Endpoint origin does not match connection origin"

Kubernetes and containerized deployments often encounter origin mismatches when service discovery mechanisms resolve differently:

# Handle origin normalization in middleware
@app.middleware("http")
async def normalize_origin(request: Request, call_next):
    # Normalize origin for consistent comparison
    origin = request.headers.get("origin", "")
    
    # Handle common Kubernetes service variations
    if "cluster.local" in origin:
        normalized = origin.replace(".cluster.local", "")
        request.headers.__dict__["_list"].append(
            (b"x-normalized-origin", normalized.encode())
        )
    
    response = await call_next(request)
    return response

This issue stems from MCP's strict origin validation. In containerized environments, ensure your server's registered endpoint matches exactly what clients use to connect. Consider using environment variables to configure consistent endpoints across deployments.

Missing Headers in HTTPS/Proxy Setups

Load balancers and reverse proxies often strip headers during TLS termination:

# Nginx configuration to preserve MCP headers
location /mcp/ {
    proxy_pass http://mcp-backend;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    
    # Preserve MCP-specific headers
    proxy_pass_header Authorization;
    proxy_pass_header Mcp-Session-Id;
    
    # CORS headers for proxied requests
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Credentials true always;
}

Configure your infrastructure to explicitly preserve protocol-specific headers. Many proxy defaults strip non-standard headers for security, requiring explicit configuration to support MCP's session management.

Examples

Production-Ready CORS Configuration

A complete example implementing security best practices for production MCP servers:

import cors from 'cors';
import helmet from 'helmet';
import express from 'express';
import { RateLimiterMemory } from 'rate-limiter-flexible';

const app = express();

// Rate limiting for CORS preflight abuse prevention
const rateLimiter = new RateLimiterMemory({
  points: 100,
  duration: 60
});

// Security headers with Helmet
app.use(helmet({
  crossOriginResourcePolicy: { policy: "cross-origin" }
}));

// CORS configuration with security considerations
const corsOptions = {
  origin: async (origin, callback) => {
    try {
      // Rate limit origin checks
      await rateLimiter.consume(origin || 'no-origin');
      
      // Fetch allowed origins from database or cache
      const allowedOrigins = await getAllowedOrigins();
      
      if (!origin || allowedOrigins.includes(origin)) {
        callback(null, true);
      } else {
        callback(new Error('CORS policy violation'));
      }
    } catch (error) {
      callback(new Error('Rate limit exceeded'));
    }
  },
  methods: ['GET', 'POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id'],
  exposedHeaders: ['Mcp-Session-Id', 'X-Request-Id', 'X-RateLimit-Remaining'],
  credentials: true,
  maxAge: 3600,
  optionsSuccessStatus: 204
};

app.use(cors(corsOptions));

// Audit CORS requests for security monitoring
app.use((req, res, next) => {
  if (req.headers.origin) {
    console.log({
      timestamp: new Date().toISOString(),
      origin: req.headers.origin,
      method: req.method,
      path: req.path,
      ip: req.ip
    });
  }
  next();
});

This production configuration implements rate limiting to prevent CORS preflight abuse, dynamic origin validation with database lookups, and comprehensive security logging. The setup balances accessibility with protection against cross-origin attacks.

WebSocket-Style Communication with SSE

Implementing bidirectional communication patterns with proper CORS:

class MCPSSETransport {
  constructor(app, corsOptions) {
    this.app = app;
    this.sessions = new Map();
    
    // Configure SSE endpoint with CORS
    this.app.get('/mcp/sse/:sessionId', (req, res) => {
      const sessionId = req.params.sessionId;
      const origin = req.headers.origin;
      
      // Validate session and origin
      if (!this.validateSession(sessionId, origin)) {
        res.status(403).json({ error: 'Invalid session or origin' });
        return;
      }
      
      // Set up SSE with proper headers
      res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache, no-transform',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no', // Disable Nginx buffering
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Credentials': 'true'
      });
      
      // Store connection for bidirectional communication
      this.sessions.set(sessionId, { res, origin });
      
      // Handle client disconnect
      req.on('close', () => {
        this.sessions.delete(sessionId);
      });
      
      // Send initial connection confirmation
      this.sendMessage(sessionId, {
        type: 'connection',
        status: 'established',
        sessionId
      });
    });
    
    // Configure POST endpoint for client-to-server messages
    this.app.post('/mcp/message/:sessionId', cors(corsOptions), (req, res) => {
      const sessionId = req.params.sessionId;
      const message = req.body;
      
      // Process message and potentially respond via SSE
      this.processMessage(sessionId, message);
      
      res.json({ status: 'received' });
    });
  }
  
  sendMessage(sessionId, data) {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.res.write(`data: ${JSON.stringify(data)}\n\n`);
    }
  }
  
  validateSession(sessionId, origin) {
    // Implement your session validation logic
    return true; // Simplified for example
  }
  
  processMessage(sessionId, message) {
    // Handle incoming messages from client
    console.log(`Message from ${sessionId}:`, message);
  }
}

This pattern enables full-duplex communication while respecting browser CORS policies. The SSE connection handles server-to-client messages, while POST requests handle client-to-server communication, creating an effective bidirectional channel for MCP protocol operations.

[Screenshot: Browser DevTools Network tab showing successful CORS preflight and SSE connection with proper headers]