MCP Server Security

MCP server JSON Schema validation security — additionalProperties enforcement, type coercion attacks, and AJV hardening

JSON Schema validation is the first line of defense against adversarial tool arguments. Permissive defaults — no additionalProperties: false, coercion enabled, no null rejection — allow attackers to bypass type checks, inject extra fields, and reach handler code with inputs the author never intended to handle.

Why JSON Schema validation matters in MCP servers

Every MCP tool handler receives arguments from the LLM. In a well-behaved flow, those arguments match the tool's declared inputSchema. Under prompt injection or adversarial prompting, the LLM may generate arguments that are structurally valid JSON but semantically unexpected: a string where a number was expected, an extra __proto__ field, a null where the handler assumes a non-null value.

The MCP host MAY validate arguments against the tool's schema before calling the handler — but this is host-dependent and version-dependent. Servers that assume the host has already validated the input are one host update away from receiving unvalidated arguments.

Attack 1: type coercion bypass via AJV default configuration

AJV's default configuration in versions prior to 8.x coerces types. A schema declaring type: "integer" accepts "42" (string) by coercing it. An MCP handler that uses the validated value assuming it's already an integer receives a string, causing downstream type errors or logic bypass:

// Vulnerable: AJV v7 with default coercion
import Ajv from 'ajv';
const ajv = new Ajv();  // coerceTypes: false is the default in v7 —
                        // but some builds/configs enable it explicitly

const validateArgs = ajv.compile({
  type: 'object',
  properties: {
    limit: { type: 'integer', minimum: 1, maximum: 100 }
  },
  required: ['limit']
});

server.tool('list_records', async (args) => {
  if (!validateArgs(args)) throw new Error('Invalid args');

  // If coercion was active: args.limit might be the string "1; DROP TABLE..."
  // after passing type validation as "integer"
  const records = await db.query(
    `SELECT * FROM records LIMIT ${args.limit}`  // injection if coercion occurred
  );
});

Fix 1: AJV strict mode with no type coercion

import Ajv from 'ajv';

// AJV 8 strict mode: no coercion, no unknown keywords, no additional props by default
const ajv = new Ajv({
  strict: true,           // enable strict mode (AJV 8+)
  coerceTypes: false,     // never coerce — type errors are type errors
  useDefaults: false,     // don't silently add default values
  removeAdditional: false // don't strip extra fields — fail instead
});

const listSchema = {
  type: 'object',
  properties: {
    limit: { type: 'integer', minimum: 1, maximum: 100 }
  },
  required: ['limit'],
  additionalProperties: false  // reject any field not in properties
};

const validateListArgs = ajv.compile(listSchema);

server.tool('list_records', async (args) => {
  if (!validateListArgs(args)) {
    const errors = validateListArgs.errors
      .map(e => `${e.instancePath} ${e.message}`)
      .join('; ');
    throw new Error(`Validation failed: ${errors}`);
  }

  // args.limit is guaranteed to be an integer between 1 and 100
  const records = await db.query(
    'SELECT * FROM records LIMIT $1',
    [args.limit]  // parameterized — safe regardless
  );
});

Attack 2: prototype pollution via additionalProperties

When additionalProperties is not set to false, the validated object may contain arbitrary extra fields from the LLM. If this object is then merged into another object or used as a key-value store, prototype pollution becomes possible:

// Vulnerable: no additionalProperties restriction
const schema = {
  type: 'object',
  properties: {
    key: { type: 'string' },
    value: { type: 'string' }
  }
  // No additionalProperties: false
};

// LLM sends: { "key": "x", "value": "y", "__proto__": { "isAdmin": true } }
// JSON.parse of a JSON string cannot produce __proto__ prototype pollution,
// but Object.assign can:
server.tool('set_config', async (args) => {
  if (!validate(args)) throw new Error('Invalid');

  // DANGEROUS: Object.assign spreads including __proto__
  Object.assign(configStore, args);  // pollutes Object.prototype.isAdmin
});

Fix 2: use Object.create(null) and explicit property copying

// SAFE: additionalProperties: false + explicit property extraction
const schema = {
  type: 'object',
  properties: {
    key: { type: 'string', maxLength: 64, pattern: '^[a-z_][a-z0-9_]*$' },
    value: { type: 'string', maxLength: 4096 }
  },
  required: ['key', 'value'],
  additionalProperties: false  // extra fields fail validation
};

server.tool('set_config', async (args) => {
  if (!validate(args)) throw new Error('Invalid');

  // Extract only declared properties — never spread the whole args object
  const { key, value } = args;

  // Use Object.create(null) stores to avoid prototype chain entirely
  configStore[key] = value;  // configStore = Object.create(null)
});

Attack 3: null injection bypassing required field checks

JSON Schema required checks that a field is present — not that it is non-null. A field set to null satisfies required but causes null.method() exceptions in handlers that don't check for null:

// Schema passes null for a required field
const schema = {
  type: 'object',
  properties: {
    filePath: { type: 'string' }  // string allows null in some JSON Schema versions
  },
  required: ['filePath']
};

// LLM sends: { "filePath": null }
// JSON Schema 'required' passes: the key exists
// 'type: string' behavior on null depends on schema draft version

server.tool('read_file', async ({ filePath }) => {
  // filePath is null
  const resolved = path.resolve(filePath);  // TypeError: null is not string
  // Exception leaks internal state — see mcp-server-error-handling-security
});

Fix 3: explicit null rejection and Zod as a higher-level alternative

// Option A: AJV with explicit null rejection
const schema = {
  type: 'object',
  properties: {
    filePath: {
      type: 'string',   // 'string' alone doesn't guarantee non-null in draft-07
      minLength: 1,     // minLength: 1 rejects empty string AND forces non-null
      maxLength: 1024
    }
  },
  required: ['filePath'],
  additionalProperties: false
};

// Option B: Zod — cleaner, non-null by default
import { z } from 'zod';

const ReadFileArgs = z.object({
  filePath: z.string().min(1).max(1024)  // non-nullable by default in Zod
}).strict();  // .strict() = additionalProperties: false equivalent

server.tool('read_file', async (rawArgs) => {
  const args = ReadFileArgs.safeParse(rawArgs);
  if (!args.success) {
    throw new Error(`Invalid args: ${args.error.flatten().fieldErrors}`);
  }
  // args.data.filePath: string — TypeScript type guaranteed, runtime-validated
  const resolved = path.resolve(args.data.filePath);
  if (!resolved.startsWith(ALLOWED_ROOT)) throw new Error('Path not allowed');
});

Attack 4: anyOf/oneOf schema with unsafe union handling

Tools that accept multiple argument shapes use anyOf or oneOf in their schema. If handler code doesn't discriminate the union correctly, it may apply logic intended for shape A to data from shape B:

// DANGEROUS: anyOf without discriminant check in handler
const schema = {
  anyOf: [
    { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
    { type: 'object', properties: { filePath: { type: 'string' } }, required: ['filePath'] }
  ]
};

server.tool('fetch_content', async (args) => {
  // LLM sends { url: 'file:///etc/passwd', filePath: '/etc/passwd' }
  // Both branches match — which one runs?
  if (args.url) await fetchUrl(args.url);  // SSRF
  if (args.filePath) await readFile(args.filePath);  // Path traversal
  // Both run. Attacker provided both fields.
});

Fix 4: discriminated union with explicit branch selection

// SAFE: discriminated union via a 'type' field
const schema = {
  type: 'object',
  properties: {
    source: { type: 'string', enum: ['url', 'file'] },
    url: { type: 'string', format: 'uri' },
    filePath: { type: 'string', minLength: 1 }
  },
  required: ['source'],
  additionalProperties: false,
  // Conditional validation based on discriminant
  if: { properties: { source: { const: 'url' } } },
  then: { required: ['url'] },
  else: { required: ['filePath'] }
};

server.tool('fetch_content', async (args) => {
  if (!validate(args)) throw new Error('Invalid');

  // Exactly one branch based on explicit discriminant
  if (args.source === 'url') {
    const parsed = new URL(args.url);
    if (!['https:'].includes(parsed.protocol)) throw new Error('Only HTTPS allowed');
    return await secureFetch(args.url);
  } else {
    // args.source === 'file' — filePath is required per schema
    const resolved = path.resolve(args.filePath);
    if (!resolved.startsWith(ALLOWED_ROOT)) throw new Error('Path not allowed');
    return await readFile(resolved, 'utf-8');
  }
});

SkillAudit detection — Security and Permissions axes

SkillAudit flags these JSON Schema validation patterns:

Servers using Zod with .strict() and .safeParse() consistently score higher on the Security axis for input validation checks than servers using raw AJV without strict mode. Run your MCP server scan at skillaudit.dev to see your input validation score. Related reading: the MCP server security checklist covers schema validation as step one of the pre-submit review.