Implementing CORS Policies for Web-Based MCP Servers

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

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: ['POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'MCP-Protocol-Version', 'Mcp-Method', 'Mcp-Name'],
  credentials: true
}));

For Python FastAPI:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["POST", "OPTIONS"],
    allow_headers=["*"],
)

CORS enables browser-based MCP clients to communicate with servers on different origins while preventing unauthorized cross-site requests. As of the 2026-07-28 spec revision, Streamable HTTP is a single POST-only endpoint — there's no GET stream and no session header to allow through CORS (see the legacy note under Configuration if you still need to support older servers).

Prerequisites

  • MCP server with Streamable HTTP 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. As of the 2026-07-28 spec revision, Streamable HTTP is a single POST-only endpoint — even the server's streaming responses are scoped to the request that triggered them via Server-Sent Events, not a separate long-lived connection — so CORS configuration mainly comes down to getting the required headers into your allow-list.

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: ['POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'MCP-Protocol-Version', 'Mcp-Method', 'Mcp-Name'],
  exposedHeaders: ['X-Request-Id'],
  credentials: true,
  maxAge: 86400 // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

The allowedHeaders configuration is what matters for a 2026-07-28 server: MCP-Protocol-Version, Mcp-Method, and Mcp-Name must all pass preflight, or a compliant client's requests never reach your handler (add any Mcp-Param-{Name} headers your server declares via x-mcp-header, too). MCP is now explicitly a stateless protocol, so there's no session header to expose.

Legacy note: most MCP clients deployed today still speak a pre-2026-07-28 revision, where the server minted an Mcp-Session-Id on initialize and the client needed it in both allowedHeaders and exposedHeaders to maintain session state, plus 'GET' in methods for the old SSE stream. If you need to support those clients, add Mcp-Session-Id back to both header lists and 'GET' back to methods.

For FastAPI implementations, configure CORS with the required protocol headers:

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=["POST", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "MCP-Protocol-Version", "Mcp-Method", "Mcp-Name"],
    expose_headers=["X-Request-Id"],
    max_age=3600
)

Usage

Handling SSE Connections (Legacy, Pre-2026-07-28)

The standalone GET /sse endpoint below belongs to the older, session-based Streamable HTTP shape (and to the two-endpoint HTTP+SSE transport before it), where the server held a persistent GET stream open per client. As of the 2026-07-28 revision there's no GET stream — SSE only ever appears as the response to an individual POST, and change notifications flow over a single long-lived POST via subscriptions/listen instead. Keep this pattern only if you still need to serve legacy clients:

// Legacy SSE endpoint with CORS headers (pre-2026-07-28)
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 legacy SSE transport maintained 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 server-to-client push under that older model.

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-Protocol-Version;
    proxy_pass_header Mcp-Method;
    proxy_pass_header Mcp-Name;
    
    # 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 per-request protocol version and method headers. (Proxying a pre-2026-07-28 server instead? Add proxy_pass_header Mcp-Session-Id; too — those servers still rely on it for session state.)

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: ['POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'MCP-Protocol-Version', 'Mcp-Method', 'Mcp-Name'],
  exposedHeaders: ['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 (Legacy Two-Endpoint Pattern)

This two-endpoint, per-session GET-stream-plus-POST pattern is the pre-2026-07-28 shape. The current spec replaces it with a single POST-only endpoint and subscriptions/listen for change notifications, with no per-client session stream to manage. Kept here for servers still supporting legacy clients:

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 under the pre-2026-07-28 shape. A current server replaces the per-session GET stream with subscriptions/listen over the single POST endpoint.

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