Implementing Content Security Policies for MCP Resources
Kashish Hora
Co-founder of AgentCat
The Quick Answer
Implement CSP headers in your MCP server to block XSS attacks and unauthorized resource loading:
// Express.js middleware for MCP server
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'nonce-{RANDOM}'; " +
"connect-src 'self' ws://localhost:* wss://*.mcpserver.com; " +
"frame-ancestors 'none'"
);
next();
});This policy restricts scripts to same-origin and nonce-validated sources, allows WebSocket connections for any real-time features your server's own web surface uses, and prevents clickjacking. (MCP's own standard transports are stdio and Streamable HTTP — WebSocket has never been one of them, though the spec permits it as a custom, non-MCP transport; either way, the CSP directives below apply the same.) Replace {RANDOM} with a cryptographically secure random value per request.
Prerequisites
- Node.js 18+ with Express.js or similar web framework
- MCP server with web interface or API endpoints
- Basic understanding of HTTP security headers
- SSL/TLS certificate for production deployments (wss:// connections)
Installation
Install Helmet.js for comprehensive security headers including CSP:
$npm install helmet
For development environments, install CSP reporting tools:
$npm install express-csp-header uuid
Configuration
Content Security Policy headers control which resources browsers can load when accessing your MCP server. Since MCP servers often handle sensitive context data and tool executions, proper CSP configuration is critical for preventing injection attacks.
Basic CSP Configuration with Helmet.js
const helmet = require('helmet');
const crypto = require('crypto');
app.use((req, res, next) => {
// Generate nonce for this request
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"], // Consider using nonces for styles too
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "ws://localhost:*", "wss://*.mcpserver.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: []
}
}));The connectSrc directive matters most if your MCP server's own web surface (a dashboard, an admin UI) opens WebSocket connections for its own real-time updates — that's unrelated to MCP's wire protocol, whose standard transports are stdio and Streamable HTTP. The frameAncestors directive prevents your MCP interface from being embedded in iframes, protecting against clickjacking attacks.
Report-Only Mode for Testing
Before enforcing CSP in production, use report-only mode to identify policy violations without breaking functionality:
app.use(helmet.contentSecurityPolicy({
directives: {
// ... same directives as above
},
reportOnly: true,
reportUri: '/csp-violation-report'
}));
// Endpoint to collect CSP violation reports
app.post('/csp-violation-report', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP Violation:', req.body);
res.status(204).end();
});Usage
Implementing Nonce-Based CSP for Dynamic Content
MCP servers often generate dynamic UI elements or execute client-side scripts for tool interactions. Use nonces to allow these while maintaining security:
// Template rendering with nonce
app.get('/dashboard', (req, res) => {
const nonce = res.locals.nonce;
res.render('dashboard', {
nonce,
cspNonce: `nonce="${nonce}"`
});
});<!-- In your template -->
<script nonce="<%= nonce %>">
// This script will execute because it has the correct nonce.
// This connects to a custom, app-level WebSocket channel your server
// exposes — not MCP's own wire protocol, whose standard transports are
// stdio and Streamable HTTP.
const realtimeClient = new RealtimeClient({
endpoint: 'wss://localhost:3000'
});
</script>Securing WebSocket Connections
If your MCP server's own web surface opens a WebSocket channel for real-time UI updates — not MCP's own wire protocol, whose standard transports are stdio and Streamable HTTP — configure CSP to allow only trusted WebSocket endpoints:
const cspDirectives = {
connectSrc: [
"'self'",
process.env.NODE_ENV === 'development' ? "ws://localhost:*" : null,
"wss://api.mcpserver.com",
"wss://tools.mcpserver.com"
].filter(Boolean)
};This configuration allows WebSocket connections to localhost in development while restricting production to specific secure endpoints.
Handling External Tool Resources
When MCP servers integrate with external tools, you may need to allow specific external resources:
// For MCP servers using external APIs or CDNs
const toolSpecificCSP = {
scriptSrc: ["'self'", "https://cdn.playwright.dev"], // For browser automation tools
imgSrc: ["'self'", "https://github.com", "https://avatars.githubusercontent.com"], // For GitHub integration
connectSrc: ["'self'", "https://api.github.com", "wss://mcp-relay.example.com"]
};Common Issues
Error: "Refused to execute inline script"
Inline scripts are blocked by CSP unless explicitly allowed. This commonly affects onclick handlers and script tags with inline code.
Root cause: CSP's script-src directive blocks all inline JavaScript by default to prevent XSS attacks. Even legitimate inline scripts from your own code are blocked without proper authorization.
// Fix: Move inline scripts to external files
// Before (blocked):
<button onclick="executeTool()">Run Tool</button>
// After (allowed):
<button id="toolButton">Run Tool</button>
<script src="/js/tools.js" nonce="<%= nonce %>"></script>Prevention: Design your MCP interface to avoid inline scripts from the start. Use event listeners in external JavaScript files and data attributes for passing configuration.
Error: "Refused to connect to 'ws://localhost:3000'"
WebSocket connections require explicit CSP permission, even to same-origin endpoints.
Root cause: The CSP connect-src directive doesn't automatically include WebSocket protocols (ws:// and wss://) when you specify 'self'. This is because WebSockets use a different protocol than HTTP/HTTPS.
// Fix: Explicitly allow WebSocket protocols
app.use(helmet.contentSecurityPolicy({
directives: {
connectSrc: [
"'self'",
"ws://localhost:3000", // Development
"wss://mcp.example.com" // Production
]
}
}));Prevention: Always include both ws:// and wss:// protocols in your connect-src directive whenever your MCP server's web surface opens its own WebSocket connections.
Error: "Multiple CSP headers detected"
Conflicting CSP headers can cause unexpected behavior and policy enforcement issues.
Root cause: CSP headers may be set at multiple levels - by your application, web framework, reverse proxy, or CDN. When multiple policies exist, content must satisfy all of them, making the effective policy more restrictive than intended.
// Fix: Check and remove duplicate CSP headers
app.use((req, res, next) => {
// Remove any existing CSP headers
res.removeHeader('Content-Security-Policy');
res.removeHeader('X-Content-Security-Policy'); // Legacy header
// Set your CSP
res.setHeader('Content-Security-Policy', 'your-policy-here');
next();
});Prevention: Document where CSP headers are set in your infrastructure and use a single source of truth for policy configuration.
MCP Apps: CSP for Interactive Tool UIs
Everything above governs CSP for your server's own conventional web surfaces — a dashboard, an admin page, a docs site. It does not apply to interactive HTML that an MCP tool returns to be rendered inside the host application (Claude, an IDE, and so on). That case has a spec-defined mechanism: the MCP Apps extension (io.modelcontextprotocol/ui), which can now be formally negotiated via the extensions capability-negotiation framework the 2026-07-28 revision introduces.
Under MCP Apps, a tool points at its UI with _meta.ui.resourceUri instead of embedding HTML in the tool result. That URI names a ui:// resource, which the host fetches and renders in a sandboxed iframe — it never runs in your server's own document, so the Helmet-based CSP you configured above doesn't reach it. CSP for that iframe is declared per-resource instead, in the resource's own _meta.ui:
{
"uri": "ui://my-server/dashboard",
"mimeType": "text/html;profile=mcp-app",
"_meta": {
"ui": {
"csp": {
"connectDomains": ["https://api.my-server.com"],
"resourceDomains": ["https://cdn.my-server.com"]
},
"permissions": { "clipboardWrite": {} }
}
}
}Note the shape here is specific to MCP Apps and doesn't reuse CSP directive syntax: csp is an object of domain arrays (connectDomains, resourceDomains, frameDomains, baseUriDomains) that the host translates into its own connect-src/img-src/etc., not a raw CSP string or directive names. permissions is likewise an object with camelCase keys (camera, microphone, geolocation, clipboardWrite) mapped to config objects, not an array of strings — the earlier CSP directives you've been writing throughout this guide don't carry over as-is.
The host merges this with its own sandbox defaults, so a tool can't use _meta.ui.csp to loosen restrictions the host enforces. The app-to-host bridge itself runs over postMessage using an MCP-shaped dialect (tools/call, ui/initialize); the @modelcontextprotocol/ext-apps helper package wraps that bridge so you aren't hand-rolling postMessage framing.
Extensions are off by default, and adoption is still rolling out — a client has to opt in to io.modelcontextprotocol/ui before _meta.ui.resourceUri does anything, and most MCP clients in the wild today don't negotiate it yet. Keep serving conventional HTTP pages with the Helmet configuration above as your fallback; reach for MCP Apps specifically when the HTML you want CSP-protected is a tool's UI delivered as an MCP resource.
Examples
Basic MCP Resource Server with Strict CSP
This example shows a minimal MCP server that serves tool definitions and resources with a strict CSP policy:
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');
const app = express();
// Generate nonce middleware
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
// Strict CSP for MCP resource server
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'none'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
manifestSrc: ["'self'"],
frameAncestors: ["'none'"],
baseUri: ["'none'"],
formAction: ["'none'"]
}
}));
// MCP tool endpoint
app.get('/tools', (req, res) => {
res.json({
tools: [{
name: 'file_reader',
description: 'Read file contents',
parameters: { path: { type: 'string' } }
}]
});
});
app.listen(3000);This configuration provides maximum security by defaulting to 'none' and explicitly allowing only necessary resources. The nonce-based approach ensures that only server-generated scripts can execute, preventing injection of malicious code even if an attacker finds an XSS vulnerability.
WebSocket-Enabled MCP Gateway with CSP
Some MCP server deployments add their own WebSocket channel alongside the standard stdio or Streamable HTTP transport — for pushing updates to a browser-based admin UI, for example. That channel is a custom addition, not part of MCP's wire protocol, but it still needs CSP protection:
const express = require('express');
const { WebSocketServer } = require('ws');
const helmet = require('helmet');
const app = express();
const server = require('http').createServer(app);
// CSP configuration for WebSocket MCP server
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'sha256-" + generateHashForInlineScript() + "'"],
styleSrc: ["'self'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: [
"'self'",
"ws://localhost:3000",
"wss://mcp-gateway.example.com",
"https://api.anthropic.com" // For model queries
],
workerSrc: ["'self'", "blob:"], // For web workers
frameAncestors: ["'none'"],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
}
}));
// Custom WebSocket channel alongside the MCP server (not MCP's own transport)
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const message = JSON.parse(data);
// Handle messages on this custom channel
if (message.method === 'tools/list') {
ws.send(JSON.stringify({
id: message.id,
result: { tools: getAvailableTools() }
}));
}
});
});
function generateHashForInlineScript() {
// Calculate SHA256 hash of your inline script
const script = 'console.log("MCP Gateway Initialized");';
return require('crypto').createHash('sha256').update(script).digest('base64');
}
server.listen(3000);Production deployments should enforce HTTPS and use wss:// for all WebSocket connections. The hash-based CSP approach shown here works well for static inline scripts that don't change frequently, while the connect-src directive ensures WebSocket connections are restricted to trusted endpoints only.
Related Guides
Implementing CORS Policies for Web-Based MCP Servers
Configure Cross-Origin Resource Sharing (CORS) policies for web-based MCP servers to enable secure browser access.
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.
Error handling in custom MCP servers
The one distinction that governs MCP error handling: protocol errors are JSON-RPC errors that fail the request, while tool failures are ordinary results with isError set true so the model can see and recover from them.