Implementing Content Security Policies for MCP Resources

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out 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.