Topic: mcp server JSON injection security

MCP server JSON injection security — unsanitized string interpolation, prototype pollution, and nested object injection

MCP servers that construct JSON responses by string interpolation rather than serialization are vulnerable to JSON injection — user-controlled content that breaks out of the intended JSON structure. Even servers using JSON.stringify correctly can be vulnerable to prototype pollution when they merge user-supplied objects with Object.assign or spread operators without checking for __proto__ or constructor keys. Both attacks can corrupt tool output that flows directly into LLM context.

1. String interpolation injection — the classic break-out

When an MCP tool handler builds a JSON response or API request body by template string interpolation, user-controlled input can inject JSON metacharacters that add, override, or escape fields in the resulting structure. The simplest injection targets a string-valued field:

// VULNERABLE: template literal constructs JSON — user controls the structure
server.tool("create_user", {
  userName: z.string(),
  email: z.string(),
}, async ({ userName, email }) => {
  // Attacker sets userName to: alice","admin":true,"x":"
  const body = `{"name":"${userName}","email":"${email}","role":"user"}`;
  // Resulting body: {"name":"alice","admin":true,"x":"","email":"...","role":"user"}
  // The downstream API now sees admin:true — injected without authentication

  const response = await fetch("https://api.internal/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
  });
  return { content: [{ type: "text", text: await response.text() }] };
});

The fix is unconditional: always build the object in JavaScript and pass it to JSON.stringify. The serializer escapes all metacharacters — quotes become \", backslashes become \\ — so user input can never break the JSON structure:

// SECURE: JSON.stringify serializes the object — no metacharacter injection possible
server.tool("create_user", {
  userName: z.string().max(64).regex(/^[a-zA-Z0-9_.-]+$/),
  email: z.string().email().max(254),
}, async ({ userName, email }) => {
  // role is hardcoded — NOT derived from user input
  const body = JSON.stringify({ name: userName, email: email, role: "user" });
  // Attacker input "alice\",\"admin\":true,\"x\":\"" becomes the literal string:
  // {"name":"alice\",\"admin\":true,\"x\":\"","email":"...","role":"user"}
  // Downstream parser sees it as a string — not JSON structure

  const response = await fetch("https://api.internal/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
  });
  return { content: [{ type: "text", text: await response.text() }] };
});

2. Prototype pollution via JSON.parse + Object.assign

Prototype pollution occurs when a user-supplied object contains a key like __proto__ or constructor that, when merged into another object via Object.assign or a spread operator, writes properties onto the shared Object.prototype. Every subsequent object created in the Node.js process inherits these poisoned properties. JSON.parse itself is safe — it creates a plain object without prototype-setting behavior. The danger is in the merge step:

// VULNERABLE: JSON.parse is safe, but Object.assign with parsed object is not
app.post("/mcp/tool/configure", async (req, res) => {
  const userConfig = JSON.parse(req.body.config); // Safe so far
  const defaults = { timeout: 5000, retries: 3, debug: false };

  // DANGEROUS: if userConfig contains {"__proto__": {"isAdmin": true}}
  // Object.assign copies __proto__ onto defaults.__proto__ = Object.prototype.__proto__
  // Every subsequent {} in this process now has .isAdmin === true
  const config = Object.assign({}, defaults, userConfig);
  // Or equivalently: const config = { ...defaults, ...userConfig };

  // Now: ({}).isAdmin === true — prototype is polluted process-wide
  applyConfig(config);
  res.json({ status: "configured" });
});

The two safe alternatives are Object.create(null) as the merge target (no prototype to pollute) and structuredClone (creates a deep copy that strips __proto__ keys):

// SECURE option 1: Object.create(null) — no prototype, nothing to pollute
app.post("/mcp/tool/configure", async (req, res) => {
  const userConfig = JSON.parse(req.body.config);

  // Create a null-prototype object as the merge target
  const config = Object.assign(Object.create(null), {
    timeout: 5000,
    retries: 3,
    debug: false,
  });

  // Merge user config — safe because the target has no prototype chain
  for (const [key, value] of Object.entries(userConfig)) {
    // hasOwnProperty check: skip __proto__ and constructor keys
    if (Object.prototype.hasOwnProperty.call(userConfig, key) &&
        key !== "__proto__" &&
        key !== "constructor" &&
        key !== "prototype") {
      config[key] = value;
    }
  }

  applyConfig(config);
  res.json({ status: "configured" });
});

// SECURE option 2: structuredClone strips __proto__ pollution vectors
app.post("/mcp/tool/configure", async (req, res) => {
  const userConfig = JSON.parse(req.body.config);
  // structuredClone performs a deep copy and does not copy __proto__ as a property
  const safeConfig = structuredClone(userConfig);

  const config = {
    timeout: 5000,
    retries: 3,
    debug: false,
    ...safeConfig, // safe to spread after structuredClone
  };

  applyConfig(config);
  res.json({ status: "configured" });
});

3. Nested object injection in tool parameters with AJV schema validation

MCP tool handlers that accept complex nested objects without strict JSON schema validation allow attackers to inject unexpected nested properties that downstream code accesses via prototype chain traversal or overly permissive property access. The fix is to apply AJV in strict mode with additionalProperties: false at every level of nesting:

import Ajv from "ajv";

const ajv = new Ajv({
  strict: true,              // Reject unknown keywords
  allErrors: false,          // Fail fast on first error
  coerceTypes: false,        // Do not coerce types — string "1" is not integer 1
  useDefaults: false,        // Do not populate defaults from schema
});

// Schema for a tool that accepts a nested configuration object
const toolInputSchema = {
  type: "object",
  additionalProperties: false, // Reject any top-level property not in the schema
  required: ["projectId", "settings"],
  properties: {
    projectId: {
      type: "string",
      pattern: "^[a-zA-Z0-9_-]{1,64}$",
    },
    settings: {
      type: "object",
      additionalProperties: false, // Reject any nested property not in the schema
      required: ["mode"],
      properties: {
        mode: { type: "string", enum: ["read", "write", "admin"] },
        timeout: { type: "integer", minimum: 1000, maximum: 30000 },
        tags: {
          type: "array",
          maxItems: 10,
          items: { type: "string", maxLength: 32 },
        },
      },
    },
  },
};

const validate = ajv.compile(toolInputSchema);

server.tool("configure_project", toolInputSchema, async (input) => {
  // Validate before any processing
  if (!validate(input)) {
    throw new Error("Invalid input: " + ajv.errorsText(validate.errors));
  }

  // At this point, input only has projectId and settings with known-safe fields
  // No __proto__, no constructor, no injected nested keys
  const { projectId, settings } = input;
  return { content: [{ type: "text", text: `Configured ${projectId}` }] };
});

4. Deep merge prototype pollution — the vulnerable deepMerge pattern

Recursive deep merge functions are common in configuration handling and are a classic prototype pollution vector when they don't check for dangerous keys. A vulnerable implementation recurses into __proto__ and writes properties onto it:

// VULNERABLE: recursive deepMerge without hasOwnProperty guard
function deepMerge(target, source) {
  for (const key in source) {
    // 'in' operator traverses the prototype chain — includes inherited keys
    if (typeof source[key] === "object" && source[key] !== null) {
      if (!target[key]) target[key] = {};
      deepMerge(target[key], source[key]); // Recurses into __proto__!
    } else {
      target[key] = source[key]; // Sets target.__proto__.polluted = true
    }
  }
  return target;
}

// Attack input: { "__proto__": { "isAdmin": true } }
// After deepMerge({}, attack), every {} in the process has .isAdmin === true

// SECURE: deepMerge with explicit key checks
function safeDeepMerge(
  target: Record<string, unknown>,
  source: Record<string, unknown>
): Record<string, unknown> {
  for (const key of Object.keys(source)) {
    // Object.keys() only returns own enumerable properties — not prototype chain
    // Also explicitly block dangerous keys
    if (key === "__proto__" || key === "constructor" || key === "prototype") {
      continue; // Skip — never merge these
    }

    const sourceVal = source[key];
    const targetVal = target[key];

    if (
      sourceVal !== null &&
      typeof sourceVal === "object" &&
      !Array.isArray(sourceVal) &&
      targetVal !== null &&
      typeof targetVal === "object" &&
      !Array.isArray(targetVal)
    ) {
      target[key] = safeDeepMerge(
        targetVal as Record<string, unknown>,
        sourceVal as Record<string, unknown>
      );
    } else {
      target[key] = sourceVal;
    }
  }
  return target;
}

5. Safe JSON output patterns for MCP tool results

When constructing tool result JSON for return to the LLM context, use a validated output schema to ensure the structure you return matches what the LLM expects — preventing injection from corrupting the tool result format that the LLM reasons over:

import { z } from "zod";

// Output schema — defines exactly what the tool result can contain
const ToolResultSchema = z.object({
  content: z.array(z.object({
    type: z.literal("text"),
    text: z.string().max(50_000), // Prevent excessively large results
  })).max(10),
  isError: z.boolean().optional(),
});

// Safe result construction: build with hardcoded structure, validate at output
function buildToolResult(text: string, isError = false) {
  const raw = {
    content: [{ type: "text" as const, text }],
    ...(isError ? { isError: true } : {}),
  };

  // Parse through Zod schema — coercion disabled, strict types
  const parsed = ToolResultSchema.parse(raw);
  return parsed;
}

// In a tool handler:
server.tool("fetch_data", { url: z.string().url() }, async ({ url }) => {
  try {
    const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
    const text = await response.text();
    // text may contain anything — but buildToolResult validates the wrapper structure
    return buildToolResult(text.slice(0, 50_000));
  } catch (err) {
    // Error message might contain sensitive paths — sanitize before returning
    const safeMessage = err instanceof Error
      ? err.message.replace(/\/[^\s]+/g, "[path]") // Strip filesystem paths
      : "Unknown error";
    return buildToolResult(`Error: ${safeMessage}`, true);
  }
});

SkillAudit findings and grade impacts

Finding → Grade Impact
Critical String interpolation in JSON response construction — user-controlled input can break out of string values and inject JSON fields that reach LLM context. −25 points.
High Object.assign or spread operator with unfiltered user-supplied object — __proto__ keys pollute the prototype chain of all subsequent objects in the process. −15 points.
High No JSON schema validation on tool inputs — nested objects and unexpected keys accepted without structure enforcement. −12 points.
High Recursive deep merge function without hasOwnProperty guard — user input containing __proto__ pollutes the prototype chain via the merge. −10 points.
Medium Error messages in tool results leak internal JSON structure — stack traces or parser errors reveal field names or schema details to the caller. −6 points.
Medium Missing additionalProperties: false in JSON schema — unexpected keys accepted in nested objects without validation or rejection. −4 points.

Audit your MCP server for JSON injection and prototype pollution. SkillAudit's static analysis detects template-literal JSON construction, unsafe Object.assign patterns, and missing schema validation on tool inputs. Run a free audit →