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
- Recursive merge or assign functions accepting unvalidated tool arguments — WARN; if the merge function does not block
__proto__,constructor, andprototypekeys, prototype pollution is possible via AI-controlled argument values _.merge(),deepmerge, or similar libraries on AI-supplied input without schema pre-validation — WARN; even patched libraries that block__proto__can be vulnerable toconstructor.prototypepaths in older versions; version and usage pattern are both checkedz.record(z.unknown())or equivalent allowing arbitrary nesting in tool schemas — INFO; a design flag that nested schemas should be reviewed for merge usage downstream- Custom deep-copy or deep-clone implementations that iterate
Object.keys()without a key blocklist — WARN;Object.keys()does not return__proto__on most engines, butfor...inandObject.getOwnPropertyNames()can — flag implementation for review
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
- MCP server input validation — Zod schema-first argument design that prevents unvalidated input from reaching internal handlers
- MCP server object injection security — related class covering non-JS object injection patterns
- MCP server deserialization security — unsafe deserialization and the broader class of object injection vulnerabilities
- MCP server security checklist — comprehensive pre-submission hardening checklist
Check your MCP server for prototype pollution vectors in tool argument handlers.
Run a free audit → How grading works →