Topic: mcp server memory safety security

MCP server memory safety security — native addon vulnerabilities, N-API binding security, Buffer misuse, and V8 heap exhaustion

Pure JavaScript MCP servers benefit from V8's memory safety guarantees — no manual allocation, no pointer arithmetic, automatic garbage collection. The moment an MCP server introduces a native addon (via N-API, nan, or a native dependency like sharp, bcrypt, or canvas), those guarantees apply only to the JavaScript layer. The native code layer operates with raw memory, and a bug there — an unchecked buffer length, a use-after-free in an async callback, an integer overflow in a size calculation — can crash the MCP server process, corrupt adjacent memory, or in some cases produce exploitable primitives. LLM-generated arguments that reach native code via tool handler parameters are particularly dangerous because they can be structurally arbitrary.

The N-API boundary problem

N-API (Node-API) is the stable ABI for native Node.js addons. Every value crossing the JavaScript-to-native boundary must be explicitly extracted and validated in C++ before use. A missing length check on a buffer argument, or an assumption that a JavaScript string argument is ASCII-only, creates a vulnerability at the boundary.

// C++ N-API addon — UNSAFE: no length check on buffer argument
napi_value ProcessBuffer(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    // No check that args[0] is actually a Buffer
    // No check that args[0].length < MAX_SAFE_SIZE
    napi_value buffer_value;
    void* data;
    size_t length;
    napi_get_buffer_info(env, args[0], &data, &length);

    // If length is attacker-controlled (e.g., 4 GB), this allocation crashes the process
    char* output = new char[length * 2];  // overflow if length > SIZE_MAX/2
    // ... process data into output ...
}
// Correct N-API addon — validate type, validate length before use
napi_value ProcessBuffer(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    // Verify argument is a Buffer type
    bool is_buffer;
    napi_is_buffer(env, args[0], &is_buffer);
    if (!is_buffer) {
        napi_throw_type_error(env, nullptr, "Argument must be a Buffer");
        return nullptr;
    }

    void* data;
    size_t length;
    napi_get_buffer_info(env, args[0], &data, &length);

    // Reject oversized input before any allocation
    const size_t MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
    if (length > MAX_BUFFER) {
        napi_throw_range_error(env, nullptr, "Buffer exceeds maximum size");
        return nullptr;
    }

    // Safe to allocate — length is bounded
    char* output = new char[length * 2 + 1];
    // ... process data ...
}

Buffer misuse in JavaScript

Even without native addons, Node.js Buffer operations can produce memory-safety-adjacent issues. The classic mistake is using Buffer.allocUnsafe() without subsequent full initialization — the allocated memory contains uninitialized heap contents that, if returned in a tool response, may leak data from previous allocations:

// MEDIUM finding — allocUnsafe without initialization leaks heap memory
export async function encode_data(args: { size: number }) {
  const buf = Buffer.allocUnsafe(args.size); // contains uninitialized memory
  // If a fill or write step is skipped due to a code path error,
  // buf.toString() returns garbage heap bytes
  return buf.toString('base64');
}

// Correct — Buffer.alloc zeroes the allocation
export async function encode_data(args: { size: number }) {
  const MAX_SIZE = 1_000_000; // 1 MB
  if (args.size < 1 || args.size > MAX_SIZE) {
    throw new McpError(ErrorCode.InvalidRequest,
      `size must be between 1 and ${MAX_SIZE}`);
  }
  const buf = Buffer.alloc(args.size); // zeroed — safe to return any subset
  // ... fill with actual data ...
  return buf.toString('base64');
}

V8 heap exhaustion from LLM-generated arguments

An LLM orchestrator can generate tool call arguments with sizes bounded only by the context window. A tool that creates a JavaScript object or array from an unbounded argument can exhaust the V8 heap before the tool call returns, crashing the entire MCP server process and terminating all active sessions:

// MEDIUM finding — no size guard before heap-intensive operation
export async function parse_json_document(args: { content: string }) {
  // If args.content is 100 MB of deeply nested JSON,
  // JSON.parse allocates a proportionally large object graph
  // V8 heap exhausts → process.exit(1) with no recovery
  const doc = JSON.parse(args.content);
  return summarize(doc);
}

// Correct — enforce input size before parsing
const MAX_JSON_BYTES = 2_000_000; // 2 MB

export async function parse_json_document(args: { content: string }) {
  if (Buffer.byteLength(args.content) > MAX_JSON_BYTES) {
    throw new McpError(ErrorCode.InvalidRequest,
      'Document exceeds maximum size (2 MB)');
  }
  const doc = JSON.parse(args.content);
  return summarize(doc);
}

Isolating native code in a Worker thread

For MCP servers that must run native addons processing LLM-supplied content (image processing, document parsing, cryptographic operations), the safest architecture runs native code in a Worker thread with a resource limit and a timeout. A crash in the Worker thread does not crash the main MCP server process:

import { Worker, isMainThread, workerData, parentPort } from 'worker_threads';
import { fileURLToPath } from 'url';

const WORKER_TIMEOUT_MS = 10_000;
const WORKER_MEMORY_MB = 256;

async function runInWorker(input: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(fileURLToPath(import.meta.url), {
      workerData: input,
      resourceLimits: {
        maxOldGenerationSizeMb: WORKER_MEMORY_MB,
        maxYoungGenerationSizeMb: 64
      }
    });

    const timer = setTimeout(() => {
      worker.terminate();
      reject(new Error('Native processing timed out'));
    }, WORKER_TIMEOUT_MS);

    worker.on('message', (result) => {
      clearTimeout(timer);
      resolve(result);
    });

    worker.on('error', (err) => {
      clearTimeout(timer);
      reject(err);
    });
  });
}

// Worker thread execution
if (!isMainThread) {
  try {
    const result = processWithNativeAddon(workerData);
    parentPort!.postMessage(result);
  } catch (err) {
    // Worker crash is isolated — main thread receives rejection, not crash
    parentPort!.postMessage({ error: String(err) });
  }
}

The resourceLimits.maxOldGenerationSizeMb limit enforces a per-Worker heap cap. When the Worker exceeds the limit, it terminates with an OOM error rather than competing with the main event loop for V8 heap space. The setTimeout terminate handles infinite-loop and hang cases.

SkillAudit detection

Memory safety issues are a niche but high-severity finding category — they occur only in servers with native code, but when they occur, the findings are almost always exploitable by an LLM-supplied oversized argument. See designing for resiliency for how Worker thread isolation fits into the broader MCP server resilience architecture, or run a SkillAudit scan to check your server's dependencies for native addon usage.