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

SkillAudit detection

SkillAudit's Security axis flags these content negotiation patterns in MCP HTTP transport implementations:

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