Topic: mcp server prototype pollution

MCP server prototype pollution — JavaScript object injection via tool arguments

Prototype pollution is a JavaScript-specific vulnerability where an attacker can contaminate Object.prototype — the root prototype that all JavaScript objects inherit from — by supplying a payload like {"__proto__": {"isAdmin": true}} as input. In an MCP server, tool arguments arrive as JSON from the AI model. If the server passes those arguments through an unsafe deep-merge or recursive assignment function, the attacker (or a malicious prompt that controls the argument values) can inject properties into Object.prototype, potentially bypassing authorization checks, altering server behavior, or enabling denial of service via property shadowing.

How prototype pollution works in MCP server handlers

Every JavaScript object inherits properties from Object.prototype. If an attacker can set a property on Object.prototype, that property becomes visible on every subsequent object created in the same process. An MCP server that passes AI-supplied tool arguments through a deep-merge function is directly exposed to this vector.

Consider a server that accepts a config argument in a tool definition and merges it into internal options:

// VULNERABLE: deep merge of user-supplied argument into options
function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === 'object') {
      deepMerge(target[key] ??= {}, source[key])
    } else {
      target[key] = source[key]
    }
  }
  return target
}

server.tool('configure', { config: z.record(z.unknown()) }, async ({ config }) => {
  const options = deepMerge({}, config)  // POLLUTION VECTOR
  return doOperation(options)
})

If the AI supplies {"__proto__": {"isAdmin": true}} as the config argument, the deepMerge call traverses to target["__proto__"] — which is Object.prototype — and sets isAdmin: true on it. Every subsequent object in the process now has isAdmin === true as an inherited property.

Four safe patterns for MCP server argument handling

1. Key-block guard before any merge

const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])

function safeDeepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (BLOCKED_KEYS.has(key)) continue  // block pollution keys
    if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
      target[key] ??= Object.create(null)  // null-prototype sub-object
      safeDeepMerge(target[key], source[key])
    } else {
      target[key] = source[key]
    }
  }
  return target
}

// Or: use a battle-tested library
// import { merge } from 'lodash-es'  // lodash v4.17.21+ blocks __proto__ by default

2. Null-prototype accumulator objects

// Objects created with Object.create(null) have no __proto__
// Pollution against them does not affect Object.prototype
const options = Object.create(null)
options.timeout = 5000
options.retries = 3

// Assigning to options.__proto__ would only set a plain
// "own" property named "__proto__" — not the prototype chain

// For accumulating parsed config from tool arguments:
server.tool('configure', configSchema, async ({ config }) => {
  const safe = Object.create(null)
  // Copy only the validated, expected keys
  if (config.timeout !== undefined) safe.timeout = Number(config.timeout)
  if (config.retries !== undefined) safe.retries = Number(config.retries)
  return doOperation(safe)
})

3. Schema-first flat argument design

import { z } from 'zod'

// PREFERRED: flat, explicitly typed arguments eliminate the need for deep merge
const configSchema = z.object({
  timeout: z.number().int().positive().max(30000).optional().default(5000),
  retries: z.number().int().min(0).max(5).optional().default(3),
  // No z.record() or z.unknown() that allows arbitrary nesting
})

server.tool('configure', configSchema, async ({ timeout, retries }) => {
  // Individual validated primitives — no object to pollute
  return doOperation({ timeout, retries })
})

4. JSON.parse with a reviver to strip dangerous keys

// If you must accept arbitrary JSON from tool arguments, parse with a reviver
function safeJsonParse(text) {
  return JSON.parse(text, (key, value) => {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      return undefined  // strip pollution keys during parse
    }
    return value
  })
}

// Note: JSON.parse itself does NOT protect against prototype pollution —
// the key "__proto__" is valid JSON and creates a regular own property.
// The reviver is the defense.

What SkillAudit checks

Scope note

Prototype pollution is a JavaScript/Node.js-specific vulnerability. Python, Go, and Rust MCP servers do not have a Object.prototype equivalent and are not subject to this class of finding. SkillAudit's prototype pollution checks apply only to servers with JavaScript/TypeScript source detected.

See also

Check your MCP server for prototype pollution vectors in tool argument handlers.

Run a free audit → How grading works →