MCP Server Security

Content Security Policy for HTTP-transport MCP servers

HTTP-transport MCP servers that serve any HTML — an admin UI, a status page, an OAuth callback — are subject to browser-based XSS attacks unless they emit strong Content Security Policy headers. This guide covers the header structure, nonce-based script policy, violation reporting, and the CSP misconfigurations SkillAudit flags.

Why CSP matters for MCP servers

Most MCP servers speak a binary protocol over stdio or a JSON-RPC stream over HTTP. But the ones with HTTP transport often grow a browser surface: an OAuth redirect handler, a settings page, a health dashboard. These pages are subject to reflected and stored XSS if they render any untrusted content. CSP is the defence-in-depth layer that limits what an injected script can do even if XSS occurs.

For MCP servers specifically, the consequence of XSS is higher than for typical web apps. A compromised server-side session can issue tool calls on the LLM's behalf, exfiltrate environment variables, or steal the API keys the server uses to call downstream services. The browser surface is small, but the blast radius of compromise is large.

Baseline CSP header for MCP servers

If your MCP server serves any HTML, the minimum viable CSP is:

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{RANDOM}';
  style-src 'nonce-{RANDOM}' 'self';
  img-src 'self' data:;
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'none';
  form-action 'self';
  upgrade-insecure-requests;

Every directive matters. default-src 'none' blocks everything not explicitly permitted. frame-ancestors 'none' prevents clickjacking. base-uri 'none' prevents <base> injection that can redirect relative URLs. upgrade-insecure-requests forces HTTP references to HTTPS.

Nonce-based script policy

The script-src 'nonce-...' directive is the most important. It requires every <script> tag to carry a cryptographically random nonce that matches the header value. Inline scripts injected by an attacker won't have the nonce and will be blocked.

import { randomBytes } from 'crypto'
import express from 'express'

const app = express()

app.use((req, res, next) => {
  const nonce = randomBytes(16).toString('base64')
  res.locals.nonce = nonce
  res.setHeader('Content-Security-Policy', [
    "default-src 'none'",
    `script-src 'nonce-${nonce}'`,
    `style-src 'nonce-${nonce}' 'self'`,
    "img-src 'self' data:",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "base-uri 'none'",
    "form-action 'self'",
    "upgrade-insecure-requests"
  ].join('; '))
  next()
})

// In your template:
// <script nonce="${nonce}">...</script>

Nonces must be regenerated per request — never reuse across responses. Using a static nonce is equivalent to using no nonce at all, since the attacker can read it from any cached page.

report-uri and CSP violation logging

Without violation reporting, CSP is silent — you don't know what it's blocking or whether attackers are probing it. Add a report-uri or the newer report-to directive pointing at a collector endpoint:

// Minimal in-process collector (log violations to stdout for now)
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  const report = req.body?.['csp-report']
  if (report) {
    console.error('CSP violation', {
      blockedUri: report['blocked-uri'],
      violatedDirective: report['violated-directive'],
      sourceFile: report['source-file'],
      lineNumber: report['line-number']
    })
  }
  res.sendStatus(204)
})

// Add to CSP header:
// report-uri /csp-report

Use Content-Security-Policy-Report-Only before switching to enforcement mode. This lets you observe violations in production without breaking functionality while you tune your policy.

Common CSP misconfigurations SkillAudit flags

upgrade-insecure-requests and HTTPS enforcement

The upgrade-insecure-requests directive instructs browsers to rewrite HTTP URLs to HTTPS before fetching. This is useful when your HTML contains references you don't fully control. It is not a substitute for HSTS — add the Strict-Transport-Security header as well:

res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')

Audit your MCP server's security headers

SkillAudit checks CSP, HSTS, X-Frame-Options, and 12 other headers on HTTP-transport servers. Paste your GitHub URL for a free graded report.

Run a free audit →