Topic: mcp server content negotiation security
MCP server content negotiation security — Accept header injection, MIME type confusion, and content-type sniffing attacks
Content negotiation in HTTP allows clients to specify the format they prefer for a response via the Accept header. When an MCP server makes outbound HTTP requests on behalf of an LLM and passes LLM-controlled values as the Accept header, it introduces an injection vector: a prompt-injected instruction can manipulate the content format the upstream API returns, potentially triggering MIME confusion in the downstream parser or causing the MCP server to return structured data in a format the LLM client processes unsafely. Content-type validation and strict enforcement close this attack class.
Attack 1 — Accept header injection via prompt injection
An MCP server that constructs outbound HTTP requests using LLM-provided arguments may pass those arguments directly as header values. A prompt-injected instruction can craft an Accept value that manipulates the upstream server's response format, triggers error conditions that leak information, or injects additional headers via CRLF sequences:
// Vulnerable: passing LLM-controlled content type to outbound request
export async function fetch_document(args: { url: string; format?: string }) {
// If args.format comes from LLM output (e.g., from a prior tool's response),
// it can contain attacker-controlled content
const response = await fetch(args.url, {
headers: {
// VULNERABILITY: LLM-controlled value injected into header
'Accept': args.format || 'application/json',
'Authorization': `Bearer ${API_KEY}`
}
});
return response.json();
}
// Attack payload injected via prompt injection in a prior tool's response:
// args.format = "application/json\r\nX-Admin-Override: true\r\nAuthorization: Bearer attacker-token"
//
// This injects two additional headers into the outbound request:
// - X-Admin-Override: true (may trigger admin mode in the upstream API)
// - Authorization: Bearer attacker-token (overwrites the legitimate bearer token)
Fix: validate the Accept value against an allowlist of permitted MIME types before passing it to the outbound request. CRLF characters in header values are rejected by most HTTP clients automatically, but an explicit check is safer:
// Fixed: allowlist-validated Accept header
const PERMITTED_ACCEPT_TYPES = new Set([
'application/json',
'text/plain',
'application/xml',
'text/html'
]);
export async function fetch_document(args: { url: string; format?: string }) {
// Validate format against allowlist — default to application/json
const acceptType = args.format && PERMITTED_ACCEPT_TYPES.has(args.format)
? args.format
: 'application/json';
// Strip any CRLF characters that somehow made it through
const safeAccept = acceptType.replace(/[\r\n]/g, '');
const response = await fetch(args.url, {
headers: {
'Accept': safeAccept,
'Authorization': `Bearer ${API_KEY}`
}
});
return response.json();
}
Attack 2 — MIME type confusion in tool responses
When an MCP tool fetches external content and returns it to the LLM, the Content-Type of the fetched resource determines how the MCP server should parse and return the content. If the server trusts the upstream Content-Type without validation, a malicious upstream server can return a resource with a misleading Content-Type that causes the MCP server to misparse the response:
// Vulnerable: trusting upstream Content-Type without validation
export async function read_api_response(args: { endpoint: string }) {
const response = await fetch(args.endpoint);
const contentType = response.headers.get('content-type') || '';
// VULNERABILITY: branching on untrusted content type
if (contentType.includes('application/json')) {
return response.json(); // trusted JSON parse
} else if (contentType.includes('text/csv')) {
return parseCSV(await response.text());
} else {
// Falls through to raw text for unknown types
return response.text(); // may include HTML with script tags
}
}
// Attack: attacker-controlled endpoint returns
// Content-Type: text/html
// Body: injected content...
//
// The LLM receives the raw HTML, and if the client renders
// tool results in a web context, XSS executes.
// Fixed: explicit response format declared by tool, not trusted from upstream
export async function read_api_response(args: { endpoint: string }) {
const response = await fetch(args.endpoint);
// Tool contract defines the expected format — do not branch on upstream Content-Type
const text = await response.text();
// Attempt JSON parse — if it fails, return as escaped plain text
try {
const parsed = JSON.parse(text);
// Re-serialize to ensure only valid JSON structure is returned
return JSON.stringify(parsed);
} catch {
// Return plain text with HTML entities escaped to prevent XSS in rich clients
return text
.replace(/&/g, '&')
.replace(//g, '>')
.slice(0, 8192); // truncate to prevent large response abuse
}
}
Attack 3 — content-type sniffing in MCP HTTP transport
MCP servers that expose an HTTP transport endpoint must set explicit Content-Type headers on every response. Browsers and HTTP clients that receive a response without a Content-Type (or with Content-Type: text/plain) may apply MIME sniffing — inferring the content type from the response body. If an attacker can influence the MCP server's response body to contain HTML or JavaScript patterns, a sniffing client may execute that content:
// Vulnerable: missing Content-Type header on MCP HTTP transport responses
app.post('/mcp', async (req, res) => {
const result = await handleMCPRequest(req.body);
// VULNERABILITY: no Content-Type set — browser may MIME-sniff
res.send(JSON.stringify(result));
});
// Fixed: explicit Content-Type with nosniff header
app.post('/mcp', async (req, res) => {
const result = await handleMCPRequest(req.body);
res.set({
'Content-Type': 'application/json; charset=utf-8',
// Prevent MIME sniffing even if Content-Type is wrong
'X-Content-Type-Options': 'nosniff',
// Prevent response from being embedded in iframes
'X-Frame-Options': 'DENY',
// Strict CSP for JSON API endpoints
'Content-Security-Policy': "default-src 'none'"
});
res.json(result);
});
Attack 4 — multipart/form-data boundary injection
MCP tools that accept file content and forward it to an upstream API using multipart/form-data are vulnerable to boundary injection if the file content is not stripped of the boundary delimiter. An attacker who controls the file content can inject a fake boundary to add arbitrary form fields to the upstream request:
// Vulnerable: user-controlled content in multipart without boundary escape
export async function upload_file(args: { filename: string; content: string }) {
const boundary = '----FormBoundary7MA4YWxkTrZu0gW';
// VULNERABILITY: content is included verbatim — if content contains
// the boundary string, the upstream server parses it as a form field boundary
const body = [
`--${boundary}`,
`Content-Disposition: form-data; name="file"; filename="${args.filename}"`,
'Content-Type: text/plain',
'',
args.content, // attacker-controlled — may contain "--" + boundary + "\r\n"
`--${boundary}--`
].join('\r\n');
// Attack payload in args.content:
// "data\r\n------FormBoundary7MA4YWxkTrZu0gW\r\n" +
// "Content-Disposition: form-data; name=\"admin\"\r\nContent-Type: text/plain\r\n\r\ntrue"
// This injects an "admin=true" field into the upstream multipart request
}
// Fixed: use a randomly generated boundary that cannot be guessed
// and use the native FormData API which handles escaping
export async function upload_file(args: { filename: string; content: string }) {
const form = new FormData();
const blob = new Blob([args.content], { type: 'text/plain' });
form.append('file', blob, args.filename);
// FormData generates a random boundary — the API handles all encoding
return fetch(UPSTREAM_API, { method: 'POST', body: form });
}
Strict content-type enforcement checklist
- All Accept headers constructed from LLM input are validated against a fixed allowlist before being passed to outbound requests.
- Upstream Content-Type values are never trusted to determine how to parse responses — the tool definition specifies the expected format.
- All HTTP transport responses include explicit
Content-Type: application/json; charset=utf-8andX-Content-Type-Options: nosniff. - Multipart form data construction uses the native
FormDataAPI, not manual string concatenation with a fixed boundary. - Tool responses that include user-controlled or external content sanitize HTML entities before returning to prevent XSS in rich-rendering clients.
SkillAudit detection
SkillAudit's Security axis flags these content negotiation patterns in MCP HTTP transport implementations:
- accept header from args — outbound fetch() calls that set the Accept header from tool argument values without allowlist validation
- upstream content-type trust — response parsing that branches on
response.headers.get('content-type')from untrusted upstream servers - no content-type header — HTTP transport responses that call
res.send()without setting Content-Type explicitly - missing nosniff header — HTTP responses missing the
X-Content-Type-Options: nosniffheader - multipart string concat — multipart form data constructed via string template literals rather than the native FormData API
Scan your MCP server's HTTP handling
SkillAudit checks for Accept header injection, MIME confusion patterns, and missing security headers in MCP server HTTP transport implementations. Free for public repos.
Request a free audit →Related security topics
- MCP server SSRF security — preventing server-side request forgery in outbound HTTP calls from tool handlers
- MCP server log injection security — CRLF injection in log output, a related injection vector
- Anatomy of a prompt injection attack — how prompt injection delivers the payloads that drive content negotiation attacks
- MCP server GraphQL injection security — injection attacks in structured query interfaces