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 <script>alert(1)</script>
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
- Raw HTML in text responses from external sources: Tool returns content fetched from web, database, or file system as plain text without HTML entity encoding, while the response is destined for rendered client contexts
- Template literal JSON construction: JSON object built as template string rather than using native JavaScript object serialization; may contain unescaped quote or backslash characters
- Binary buffer converted to string without base64:
Buffer.toString()without'base64'argument used for binary data in tool responses - Missing mimeType on structured content items: Resource or image content items returned without explicit
mimeTypefield - No response encoding validation: Tool handlers that return external content (file reads, API responses, web fetches) without a final JSON-serialization validation step
— SkillAudit scans for these patterns automatically. Scan your MCP server.