Implementing CORS Policies for Web-Based MCP Servers
Kashish Hora
Co-founder of 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-Idoninitializeand the client needed it in bothallowedHeadersandexposedHeadersto maintain session state, plus'GET'inmethodsfor the old SSE stream. If you need to support those clients, addMcp-Session-Idback to both header lists and'GET'back tomethods.
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 responseThis 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]
Related Guides
Implementing Content Security Policies for MCP Resources
Protect MCP server resources with Content Security Policy (CSP) headers to prevent XSS and injection attacks.
Building a Streamable HTTP MCP server
Build a remote MCP server on the Streamable HTTP transport with the official TypeScript SDK, in the shape the 2026-07-28 spec revision requires: one POST-only endpoint, the headers that bite people, OAuth, and scaling out now that sessions are gone.
Security tests for MCP server endpoints
Security-test an MCP server with real, verifiable tools: scan tool descriptions for poisoning and line jumping, probe the Streamable HTTP endpoint with the Inspector CLI and curl, and check OAuth Resource Server behavior against the 2026-07-28 spec.