Topic: mcp server stdio injection

MCP server stdio injection — JSON-RPC channel injection attacks and mitigations

The MCP stdio transport is line-delimited JSON-RPC: each message is a single JSON object terminated by a newline (\n). The MCP SDK handles this framing correctly for well-formed inputs. The risk is what happens when a tool argument or tool response contains bytes that can break or manipulate the framing: null bytes that truncate JSON string parsing, embedded newlines that split a single message into two frames, and oversized messages that exhaust the receiving process's input buffer. These are not theoretical — they arise naturally when MCP servers process file contents, terminal output, or user-supplied text and pass it through the stdio channel without sanitization.

Pattern 1: embedded newline injection via manual JSON assembly

The most common stdio injection pattern is manual string concatenation to build JSON-RPC responses. When a tool's output contains a newline character and the server assembles the response via string concatenation instead of JSON.stringify, the newline appears literally in the output stream and splits the message frame.

// WRONG: manual JSON assembly — newlines in content break the frame
const fileContent = await fs.readFile(path, 'utf8');
// If fileContent contains newlines (it almost certainly does),
// this splits the frame:
process.stdout.write(
  '{"jsonrpc":"2.0","id":' + requestId + ',"result":{"content":"' + fileContent + '"}}\n'
);
// The line break inside fileContent becomes a literal \n in the output stream,
// creating a spurious new frame that the MCP client tries to parse as JSON.

// CORRECT: always use JSON.stringify — it escapes newlines to \\n
const response = {
  jsonrpc: '2.0',
  id: requestId,
  result: {
    content: [{ type: 'text', text: fileContent }]
  }
};
process.stdout.write(JSON.stringify(response) + '\n');
// JSON.stringify converts internal newlines to \\n — safe in any frame

The fix is always JSON.stringify — never string concatenation for JSON assembly. This is basic JSON hygiene but it is the most common stdio injection source in corpus servers that implement custom serialization or build tool responses in ad-hoc ways.

Pattern 2: null byte injection in binary content

JSON is a text format, but MCP servers frequently process binary content: file reads on binary files, base64-decoded data, terminal output with escape sequences. A null byte (\x00) inside a JSON string is technically valid in JSON (it should be encoded as ), but some JSON parsers, particularly older versions and native bindings, truncate strings at the first null byte.

// WRONG: passing binary buffer directly as a string — contains null bytes
const binaryData = await fs.readFile(binaryFilePath);  // Buffer, not string
const response = {
  jsonrpc: '2.0',
  id: requestId,
  result: {
    content: [{ type: 'text', text: binaryData.toString() }]  // null bytes inside
  }
};
// JSON.stringify encodes null bytes as , but the receiving parser
// may truncate at the first null byte depending on implementation.

// CORRECT: base64-encode binary content for transmission
const response = {
  jsonrpc: '2.0',
  id: requestId,
  result: {
    content: [{
      type: 'text',
      text: `[binary file — base64 encoded]\n${binaryData.toString('base64')}`
    }]
  }
};

// OR: detect binary content and return metadata instead of raw bytes
async function readFileForMcp(filePath: string) {
  const buffer = await fs.readFile(filePath);
  const isBinary = buffer.includes(0x00);  // null byte = binary indicator
  if (isBinary) {
    return {
      type: 'binary',
      encoding: 'base64',
      size: buffer.length,
      content: buffer.toString('base64')
    };
  }
  return { type: 'text', content: buffer.toString('utf8') };
}

Pattern 3: control character injection in terminal output

MCP servers that run subprocesses and return terminal output face a specific injection class: ANSI escape sequences and control characters in the subprocess output. These are valid in a terminal but can interfere with JSON framing when passed through the stdio channel without sanitization.

import { execFile } from 'child_process';
import { promisify } from 'util';
import stripAnsi from 'strip-ansi';

const execFileAsync = promisify(execFile);

// WRONG: raw subprocess output — may contain ANSI escapes, \r, \x1b, etc.
async function runCommandTool(command: string, args: string[]) {
  const { stdout, stderr } = await execFileAsync(command, args);
  return { stdout, stderr };  // raw bytes — may contain control characters
}

// CORRECT: strip ANSI escapes and non-printable control characters
function sanitizeTerminalOutput(raw: string): string {
  // Strip ANSI escape sequences (colors, cursor moves, etc.)
  const stripped = stripAnsi(raw);
  // Remove non-printable ASCII except tab (\t) and newline (\n):
  return stripped.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, '');
  // \t (\x09) and \n (\x0a) are allowed — tab and newline are printable in output
}

async function runCommandTool(command: string, args: string[]) {
  const { stdout, stderr } = await execFileAsync(command, args);
  return {
    stdout: sanitizeTerminalOutput(stdout),
    stderr: sanitizeTerminalOutput(stderr)
  };
}

Pattern 4: oversized message flooding

The MCP stdio transport has no built-in message size limit. A tool that reads and returns a large file (a 500MB log, a database dump) will write the entire content as a single JSON-RPC message to stdout. The receiving MCP client must buffer the entire message before parsing it. A client with a 128MB input buffer throws an error; a client with no limit allocates until OOM.

const MAX_RESPONSE_BYTES = 512 * 1024;  // 512 KB per tool response

// Enforce in tool handler:
function truncateIfOversized(content: string, maxBytes: number): string {
  const buf = Buffer.from(content, 'utf8');
  if (buf.length <= maxBytes) return content;

  const truncated = buf.subarray(0, maxBytes).toString('utf8');
  // Remove any partial multi-byte character at the truncation point:
  const cleaned = truncated.replace(/[�]|\uD800-\uDFFF/g, '');
  return cleaned + `\n\n[Response truncated at ${maxBytes} bytes. ` +
    `Full content: ${buf.length} bytes]`;
}

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const rawContent = await getToolResult(request);
  const safeContent = truncateIfOversized(rawContent, MAX_RESPONSE_BYTES);
  return {
    content: [{ type: 'text', text: safeContent }]
  };
});

The truncation approach is better than an error: the LLM receives the first 512KB and a clear indicator that content was truncated, and can request the remainder or summarize what it has. An error response is less useful because the LLM cannot partially process the content.

The MCP SDK's built-in protections

The official MCP TypeScript SDK (@modelcontextprotocol/sdk) handles JSON serialization correctly: it uses JSON.stringify for all output and reads input line-by-line with proper newline framing. These injection patterns only arise when server code bypasses the SDK's serialization (manual process.stdout.write calls, custom transport implementations) or returns tool content via the SDK with unsafe content in it (binary buffers, raw subprocess output). The fix for Patterns 1 and 2 is to route all output through the SDK's response types — { content: [{ type: 'text', text: ... }] } — rather than raw process streams. Patterns 3 and 4 require the application-layer sanitization above regardless of transport.

What SkillAudit checks

The security axis checks for stdio injection patterns via static analysis:

See also

Check your server for stdio injection findings before publishing.

Run a free audit → How grading works →