Topic: tool output encoding security

MCP server tool output encoding security — HTML injection, JSON escaping, binary data, encoding context mismatches

An MCP tool response travels from the server to two distinct consumers: the LLM (which processes the text as context) and the client UI (which may render it). Each consumer has different encoding requirements. Text that is correctly encoded for JSON transport may contain raw HTML that the client renders as markup. Binary data returned as a raw string corrupts JSON framing. Code snippets containing backslash sequences or template literals can break client-side string interpolation. This page covers five output encoding security patterns: HTML entity encoding for rendered contexts, JSON string escaping at tool boundaries, base64 encoding for binary data, content-type tagging, and encoding validation before return.

1. HTML entity encoding for rendered contexts

MCP tool responses are text values. Some MCP clients render tool response text as HTML — in browser-based clients, Electron wrappers, or web-based agent UIs. When a tool returns content derived from user-controlled input or external sources (web pages, issue bodies, email content, document text) without HTML encoding, that content can contain <script> tags, event handlers, or other HTML constructs that execute when rendered.

Apply HTML entity encoding to any string in a tool response that could contain characters with special HTML meaning, before the string is embedded in the response object. Use a well-tested encoding library rather than manual character replacement — manual replacement commonly misses character variants, Unicode representations, or context-specific encoding rules.

import he from 'he'  // HTML entities library — handles all HTML5 named + numeric entities

// VULNERABLE: returning raw HTML-bearing content
async function getIssueBodyVulnerable(args: { issueId: string }): Promise<object> {
  const issue = await db.query('SELECT body FROM issues WHERE id=$1', [args.issueId])
  return {
    content: [{ type: 'text', text: issue.rows[0].body }]  // body may contain <script>
  }
}

// SAFE: encode HTML-bearing content before returning
async function getIssueBodySafe(args: { issueId: string }): Promise<object> {
  const issue = await db.query('SELECT body FROM issues WHERE id=$1', [args.issueId])
  const rawBody: string = issue.rows[0].body

  return {
    content: [{
      type: 'text',
      // he.encode converts <script>alert(1)</script> to &lt;script&gt;alert(1)&lt;/script&gt;
      text: he.encode(rawBody, { useNamedReferences: true })
    }]
  }
}

// For content explicitly intended to contain markup (like returned HTML previews),
// tag it explicitly and let the client decide how to render it
async function getHtmlPreview(args: { url: string }): Promise<object> {
  const html = await fetchPageHtml(args.url)

  // Return with explicit MIME type — client knows this is HTML, not plain text
  return {
    content: [{
      type: 'resource',
      resource: {
        uri: args.url,
        mimeType: 'text/html',
        // Still encode for JSON transport — the MIME type tells client it's HTML
        text: html
      }
    }]
  }
}

2. JSON string escaping at tool boundaries

Tool responses are serialized as JSON. When a tool builds its response by string concatenation or template literal interpolation rather than using JSON.stringify(), it produces JSON that may contain unescaped special characters: backslashes (\), double quotes ("), newlines (\n), or null bytes. These corrupt the JSON framing and may cause parse errors, truncated messages, or — in pathological cases — JSON injection that modifies the structure of the response object.

// JSON STRING INJECTION VULNERABILITY
// If fileContent contains: {"injected": true, "text": "
// the template literal breaks the JSON structure

async function readFileVulnerable(args: { path: string }): Promise<string> {
  const content = await fs.readFile(args.path, 'utf8')

  // WRONG: manual JSON construction — unescaped quotes in content break the JSON
  return `{"content": [{"type": "text", "text": "${content}"}]}`
}

// SAFE: always use JSON serialization methods
async function readFileSafe(args: { path: string }): Promise<object> {
  const content = await fs.readFile(args.path, 'utf8')

  // Return a plain object — the MCP SDK handles JSON serialization
  return {
    content: [{
      type: 'text',
      text: content  // SDK calls JSON.stringify on the response — escaping is automatic
    }]
  }
}

// If you MUST serialize to JSON string manually (e.g., for a nested JSON value in text),
// use JSON.stringify — never template literals
function embedJsonInText(data: unknown): string {
  const jsonStr = JSON.stringify(data, null, 2)  // properly escaped
  return `Here is the data:\n\`\`\`json\n${jsonStr}\n\`\`\``
}

// Validate the final response serializes without error
function validateResponseEncoding(response: unknown): void {
  try {
    JSON.stringify(response)
  } catch (err) {
    throw new Error(`Tool response failed JSON serialization: ${err instanceof Error ? err.message : String(err)}`)
  }
}

3. Base64 encoding for binary data

MCP text transport is UTF-8. Binary data — images, PDFs, compiled binaries, compressed archives — contains byte sequences that are not valid UTF-8 and cannot be reliably transported as text strings. Returning binary data as raw bytes corrupts the JSON message and produces unpredictable results at the client: some clients silently drop non-UTF-8 content, others replace it with replacement characters, and some crash. The correct encoding for binary data in MCP responses is base64, with an explicit MIME type annotation.

import { readFile } from 'fs/promises'
import { lookup as mimeType } from 'mime-types'

// VULNERABLE: returning binary as raw string
async function readImageVulnerable(args: { path: string }): Promise<object> {
  const buffer = await readFile(args.path)
  return {
    content: [{
      type: 'text',
      text: buffer.toString()  // NOT UTF-8 — corrupts JSON transport for PNG/JPEG/PDF
    }]
  }
}

// SAFE: base64-encode binary, provide MIME type
async function readImageSafe(args: { path: string }): Promise<object> {
  // Allowlist extensions to prevent arbitrary file reads
  const allowedExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']
  const ext = path.extname(args.path).toLowerCase()

  if (!allowedExtensions.includes(ext)) {
    throw new McpError(
      ErrorCode.InvalidParams,
      `File type ${ext} is not supported. Allowed: ${allowedExtensions.join(', ')}`
    )
  }

  const buffer = await readFile(args.path)
  const mime = mimeType(args.path) || 'application/octet-stream'

  // MCP image content type — includes mimeType and base64 data
  return {
    content: [{
      type: 'image',
      data: buffer.toString('base64'),   // base64-encoded binary
      mimeType: mime                     // explicit content type for client rendering
    }]
  }
}

// For mixed responses (text + binary), return both content items
async function generateReportWithChart(args: { reportId: string }): Promise<object> {
  const [reportText, chartPng] = await Promise.all([
    fetchReportText(args.reportId),
    renderChartToPng(args.reportId)
  ])

  return {
    content: [
      { type: 'text', text: reportText },
      { type: 'image', data: chartPng.toString('base64'), mimeType: 'image/png' }
    ]
  }
}

4. Content-type tagging

MCP tool responses can contain text, images, or embedded resources. When a response contains structured data (JSON, CSV, XML) embedded as a text string, the client and LLM need to know how to interpret it. Without explicit content-type tagging, the client may attempt to render JSON as plain text (losing structure), or the LLM may treat a CSV as prose (losing semantic meaning). Tag every non-plain-text content item with an appropriate MIME type.

// Content-type constants — use these rather than inline strings
const CONTENT_TYPES = {
  PLAIN_TEXT: 'text/plain',
  JSON: 'application/json',
  CSV: 'text/csv',
  MARKDOWN: 'text/markdown',
  HTML: 'text/html',
  XML: 'application/xml',
  PNG: 'image/png',
  PDF: 'application/pdf'
} as const

// Tag structured data responses with explicit content type
async function exportMetricsHandler(args: { format: 'json' | 'csv'; from: string; to: string }) {
  const data = await fetchMetrics(args.from, args.to)

  if (args.format === 'json') {
    return {
      content: [{
        type: 'resource',
        resource: {
          uri: `metrics:${args.from}:${args.to}`,
          mimeType: CONTENT_TYPES.JSON,
          text: JSON.stringify(data, null, 2)
        }
      }]
    }
  }

  if (args.format === 'csv') {
    const csv = toCsv(data)
    return {
      content: [{
        type: 'resource',
        resource: {
          uri: `metrics:${args.from}:${args.to}`,
          mimeType: CONTENT_TYPES.CSV,
          text: csv
        }
      }]
    }
  }

  throw new McpError(ErrorCode.InvalidParams, `Unsupported format: ${args.format}`)
}

// When returning code snippets, use markdown with language tag
function wrapCodeSnippet(code: string, language: string): string {
  const safeLang = language.replace(/[^a-zA-Z0-9+#-]/g, '')  // allowlist language identifier
  return `\`\`\`${safeLang}\n${code}\n\`\`\``
}

5. Encoding validation at tool return

The final defense is a validation gate at the point where the tool handler returns its response. Before the response object reaches the MCP transport, verify that it can be serialized to valid JSON without error, that all text values are valid UTF-8 strings, and that binary content is properly base64-encoded. This catches encoding errors introduced anywhere in the handler's call chain — not just at the final assembly point.

interface ContentItem {
  type: 'text' | 'image' | 'resource'
  text?: string
  data?: string        // base64 for images
  mimeType?: string
  resource?: { uri: string; mimeType?: string; text?: string; blob?: string }
}

function validateContentItem(item: ContentItem, index: number): void {
  if (item.type === 'text') {
    if (typeof item.text !== 'string') {
      throw new Error(`content[${index}].text must be a string, got ${typeof item.text}`)
    }
    // Verify valid UTF-8 by round-tripping through Buffer
    const reEncoded = Buffer.from(item.text, 'utf8').toString('utf8')
    if (reEncoded !== item.text) {
      throw new Error(`content[${index}].text contains invalid UTF-8 sequences`)
    }
  }

  if (item.type === 'image') {
    if (typeof item.data !== 'string') {
      throw new Error(`content[${index}].data must be a base64 string`)
    }
    // Verify valid base64
    if (!/^[A-Za-z0-9+/]*={0,2}$/.test(item.data)) {
      throw new Error(`content[${index}].data is not valid base64`)
    }
    if (!item.mimeType) {
      throw new Error(`content[${index}].mimeType is required for image content`)
    }
  }
}

function validateToolResponse(response: { content: ContentItem[] }): void {
  // 1. Verify JSON serialization succeeds
  try {
    JSON.stringify(response)
  } catch (err) {
    throw new Error(`Response failed JSON serialization: ${err instanceof Error ? err.message : String(err)}`)
  }

  // 2. Validate each content item's encoding
  for (let i = 0; i < response.content.length; i++) {
    validateContentItem(response.content[i], i)
  }
}

// Use as a decorator or wrapper on all tool handlers
function withEncodingValidation<T extends unknown[], R extends { content: ContentItem[] }>(
  handler: (...args: T) => Promise<R>
): (...args: T) => Promise<R> {
  return async (...args: T): Promise<R> => {
    const response = await handler(...args)
    validateToolResponse(response)
    return response
  }
}

SkillAudit checks for tool output encoding

SkillAudit scans for these patterns automatically. Scan your MCP server.