Topic: schema evolution security

MCP server tool schema evolution security — backward-compatible changes, additive injection surfaces, strict versioning

A schema change that adds an optional argument to an MCP tool is backward-compatible: existing clients that do not pass the new field continue to work. It may not be security-compatible: the new optional field might be passed to a function that uses it in a database query, an upstream API call, or a file path operation — and if the field lacks the same input validation constraints as the required fields, it becomes a new injection surface. Schema evolution introduces security risk not from breaking changes, which are obvious and audited, but from additive changes that appear harmless. This page covers five schema evolution security patterns: auditing new optional fields for injection exposure, preventing permissive validation defaults during migration, enforcing additionalProperties: false through schema versions, schema version negotiation confusion, and deprecating fields without creating validation gaps.

1. New optional fields as injection surfaces

When a required argument is added to a tool schema, it goes through the same review process as all required arguments: does it need length limits? an allowlist of values? a pattern constraint? Optional arguments added later often skip this review because they feel low-stakes — the feature works without them, so their security implications are not fully considered.

// Schema version 1.0 — secure required field with constraints
const toolSchemaV1 = {
  type: 'object',
  properties: {
    repo: {
      type: 'string',
      pattern: '^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$', // owner/repo format enforced
      maxLength: 200,
    },
  },
  required: ['repo'],
  additionalProperties: false,
}

// Schema version 1.1 — adds optional "filter" argument without security review
// BUG: the filter argument reaches a database LIKE clause without input validation
const toolSchemaV1_1_DANGEROUS = {
  type: 'object',
  properties: {
    repo: {
      type: 'string',
      pattern: '^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$',
      maxLength: 200,
    },
    filter: {
      type: 'string', // no pattern, no maxLength — any string is accepted
      description: 'Optional filter for results',
    },
  },
  required: ['repo'],
  additionalProperties: false,
}

// SAFE: apply the same constraints to optional fields
const toolSchemaV1_1_SAFE = {
  type: 'object',
  properties: {
    repo: {
      type: 'string',
      pattern: '^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$',
      maxLength: 200,
    },
    filter: {
      type: 'string',
      pattern: '^[a-zA-Z0-9 _-]*$', // allowlist: alphanumeric, space, hyphen, underscore only
      maxLength: 100,                // prevents oversized LIKE patterns
      description: 'Optional filter — alphanumeric characters only',
    },
  },
  required: ['repo'],
  additionalProperties: false,
}

// Handler must also use parameterized queries, not string interpolation
async function listIssues(args: { repo: string; filter?: string }): Promise<object[]> {
  const { repo, filter } = args
  // DANGEROUS: string interpolation of optional field
  // const result = await db.query(`SELECT * FROM issues WHERE repo = '${repo}' AND title LIKE '%${filter}%'`)

  // SAFE: parameterized query — filter is a bind parameter, not concatenated
  const result = await db.query(
    `SELECT id, title, state FROM issues WHERE repo = $1 AND ($2::text IS NULL OR title ILIKE $3)`,
    [repo, filter ?? null, filter ? `%${filter}%` : null],
  )
  return result.rows
}

2. Preventing permissive validation defaults during migration

Schema migration often introduces a temporary "lenient mode" — validating with coerceTypes: true, removing strict: true, or using allErrors: true — to ease the transition from old client behavior. These concessions are meant to be temporary but often become permanent. Each concession widens the validation gap: coerceTypes converts the string "1" into the number 1 for a field that should be numeric, potentially bypassing validation that checks for numeric type before running arithmetic; allErrors: true collects all errors but does not stop processing, meaning partially-invalid input may still reach handler logic before the validation result is checked.

import Ajv from 'ajv'

// DANGEROUS: lenient validation settings that undermine security guarantees
const lenientAjv = new Ajv({
  coerceTypes: true,    // string "true" becomes boolean true — bypasses type checks
  allErrors: true,      // collects all errors but does not reject on first failure
  strict: false,        // allows unknown keywords in schema — schema errors are silently ignored
  removeAdditional: true, // silently strips additional properties instead of rejecting
})
// PROBLEM: removeAdditional + coerceTypes + allErrors means a partially-invalid input
// that should have been rejected may reach the handler with coerced (and potentially
// unexpected) values.

// SAFE: strict validation settings — fail fast, no coercion
const strictAjv = new Ajv({
  coerceTypes: false,   // no implicit type conversion — reject type mismatches explicitly
  allErrors: false,     // fail on first error — do not process further
  strict: true,         // unknown schema keywords are errors — catch schema author mistakes
  removeAdditional: false, // do not silently drop fields — reject instead (via additionalProperties:false)
})

function validateStrict(schema: object, data: unknown): void {
  const validate = strictAjv.compile(schema)
  if (!validate(data)) {
    const msg = validate.errors?.map(e => `${e.instancePath || '.'}: ${e.message}`).join('; ')
    throw new TypeError(`Validation failed: ${msg}`)
  }
  // If strict validation passes, data is exactly what the schema declares — no coerced surprises
}

// Migration pattern: if you need to support old clients that pass string numbers,
// use an explicit transform BEFORE validation — not coercion during validation
function normalizeArgs(args: Record<string, unknown>): Record<string, unknown> {
  const normalized = { ...args }
  if (typeof normalized.count === 'string') {
    const n = parseInt(normalized.count, 10)
    if (isNaN(n)) throw new TypeError('count must be a number')
    normalized.count = n
  }
  return normalized
  // Now validate the normalized object with strict validation
}

3. Enforcing additionalProperties: false across schema versions

Removing a field from a schema does not automatically cause the handler to reject that field — it causes the schema validation to ignore it. If the handler still reads args.oldField (because it was not cleaned up), and the schema allows additional properties (or does not have additionalProperties: false), then clients sending the old field will have it silently accepted and processed without validation. The removed field is now an unvalidated input.

// Schema v1.0: has "format" field
const v1Schema = {
  type: 'object',
  properties: {
    path: { type: 'string', maxLength: 256 },
    format: { type: 'string', enum: ['json', 'csv', 'text'] },
  },
  required: ['path'],
  additionalProperties: false, // enforced
}

// Schema v1.1: "format" removed (deprecated — always returns JSON now)
const v1_1Schema_DANGEROUS = {
  type: 'object',
  properties: {
    path: { type: 'string', maxLength: 256 },
    // "format" removed — but additionalProperties is missing
  },
  required: ['path'],
  // BUG: no additionalProperties: false — clients still sending "format" have it pass through
  // The handler might still read args.format (leftover code) with no validation
}

const v1_1Schema_SAFE = {
  type: 'object',
  properties: {
    path: { type: 'string', maxLength: 256 },
    // "format" intentionally absent
  },
  required: ['path'],
  additionalProperties: false, // any extra field (including legacy "format") is rejected
}

// Field deprecation sequence (safe order):
// 1. Add deprecation notice to field description — "DEPRECATED: will be removed in v1.1"
// 2. Update handler to ignore the field (stop reading args.format) while still validating it
// 3. Remove the field from the schema AND the handler in the same release
// NEVER: remove validation before removing the handler code
// NEVER: remove the field from schema but leave the handler reading it

// Validate against all schema versions a client might use — not just the latest
const schemaRegistry = new Map([
  ['1.0', v1Schema],
  ['1.1', v1_1Schema_SAFE],
])

function validateByVersion(clientSchemaVersion: string, args: unknown): void {
  const schema = schemaRegistry.get(clientSchemaVersion) ?? v1_1Schema_SAFE
  validateStrict(schema, args)
}

4. Schema version negotiation confusion

When an MCP server serves different schema versions to different clients, a version mismatch can cause unexpected validation behavior. A client that received schema v1.0 (which requires a format field) will always send format; if the server validates with schema v1.1 (which rejects additional properties), every request from this client fails. More dangerous is the reverse: a client that received schema v1.1 (which does not include a filter field) operates against a server still running schema v1.0 validation — the client can send fields that v1.1 removed but that v1.0 accepts and processes without the constraints added in v1.1.

// MCP server initialization response: declare the schema version in capabilities
// so clients know which schema to validate against locally

function buildServerCapabilities(schemaVersion: string) {
  return {
    protocolVersion: '2025-01-01',
    capabilities: { tools: {} },
    serverInfo: {
      name: 'my-mcp-server',
      version: '1.1.0',
      schemaVersion, // clients cache this and include it in validation
    },
  }
}

// Handler: validate against the schema version the client declared, not the latest
// The client should echo back the schemaVersion from initialization in each request
// (or it is carried as a transport-level session attribute)

function handleToolCall(
  sessionSchemaVersion: string,
  toolName: string,
  args: unknown,
): void {
  // Validate args against the schema version the client received at initialization
  validateByVersion(sessionSchemaVersion, args)

  // If sessionSchemaVersion is older than current: apply field migrations before handler
  const migratedArgs = migrateArgs(sessionSchemaVersion, args as Record<string, unknown>)
  runHandler(toolName, migratedArgs)
}

function migrateArgs(
  fromVersion: string,
  args: Record<string, unknown>,
): Record<string, unknown> {
  if (fromVersion === '1.0') {
    // v1.0 had "format" field — strip it before passing to v1.1 handler
    const { format: _dropped, ...rest } = args
    return rest
  }
  return args
}

5. Field deprecation without validation gaps

A deprecated field that is still accepted by the server but no longer documented creates a hidden attack surface. The field may have been validated in earlier versions; if the validation logic was removed along with the documentation but the handler still reads the field (perhaps via a catch-all { ...args } spread), the field is now an unvalidated input that reaches server logic. Deprecation must follow a strict order: first stop consuming the field in handler logic, then remove it from the schema with a validation gate that explicitly rejects it.

// DANGEROUS deprecation order:
// 1. Remove field from schema (no longer validated)
// 2. Keep reading args.legacyField in handler — now receives unvalidated input
// 3. Remove from docs — field is a hidden, unvalidated injection surface

// SAFE deprecation sequence:

// Phase 1: soft deprecation — field still validated, handler logs usage
const phase1Schema = {
  type: 'object',
  properties: {
    query: { type: 'string', maxLength: 500 },
    legacyFilter: {
      type: 'string',
      maxLength: 100,
      pattern: '^[a-zA-Z0-9_-]*$',
      description: 'DEPRECATED: use "query" parameter instead. Will be removed in v2.0.',
    },
  },
  required: ['query'],
  additionalProperties: false,
}

function handlePhase1(args: { query: string; legacyFilter?: string }): void {
  if (args.legacyFilter !== undefined) {
    console.warn({ event: 'deprecated_field_used', field: 'legacyFilter', sessionId: 'session' })
  }
  // Handler still accepts and processes legacyFilter, but logs usage for monitoring
  executeSearch(args.query, args.legacyFilter)
}

// Phase 2: stop consuming — handler ignores the field, schema still validates it
function handlePhase2(args: { query: string; legacyFilter?: string }): void {
  // legacyFilter intentionally ignored — but schema still validates it if present
  // This prevents an unvalidated field from sneaking through before schema is updated
  executeSearch(args.query, undefined)
}

// Phase 3: remove from schema with additionalProperties: false — field now rejected
const phase3Schema = {
  type: 'object',
  properties: {
    query: { type: 'string', maxLength: 500 },
    // legacyFilter removed
  },
  required: ['query'],
  additionalProperties: false, // clients still sending legacyFilter receive a validation error
}

// The key invariant: at no point does a field exist that reaches handler logic
// without being validated by the current schema

What SkillAudit checks

SkillAudit's static analysis examines tool schemas and handler code for these schema evolution security patterns:

Run a free SkillAudit scan to check your MCP server's schema evolution security posture. The Security and Permissions sub-scores both cover schema validation gaps and injection surfaces introduced by schema changes.