Topic: JSON Schema validation security
MCP server JSON Schema validation security — Zod vs JSON Schema, anyOf anti-patterns, additionalProperties: false
MCP tool handlers receive arguments from Claude as plain JavaScript objects. There is no framework-level type enforcement between the JSON-RPC message and your tool logic — only the validation you write. Zod is the right tool for that validation, but Zod schemas can be written loosely or strictly. A loose schema that uses anyOf to accept multiple types, omits .strict(), makes security-critical fields optional, or allows null where only strings are safe is not validation — it is documentation with false confidence. Five patterns that make Zod schemas actually enforce the boundaries they claim to enforce.
1. Always validate at the tool boundary
The MCP SDK's server.tool() method accepts a Zod schema as its second argument, and the SDK passes parsed arguments to the handler. This is the correct validation boundary — the SDK rejects calls that fail the schema before your handler executes. However, the boundary breaks down when developers put the schema inside the handler body, perform validation inside a helper that is not always called, or split the schema from the handler so that the schema can drift out of sync with what the handler actually expects.
The distinction between z.parse() and z.safeParse() also matters at this boundary. z.parse() throws a ZodError on validation failure — which is correct behavior at the boundary, as long as the outer error handler catches it and returns a proper error response. z.safeParse() returns a result object and never throws, which is appropriate when validation failure is a normal branch rather than an exceptional condition. Use z.parse() at the boundary (the MCP SDK handles this for you), and z.safeParse() when you are validating untrusted data within your own code where you want to inspect the validation errors rather than catch an exception.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
const server = new McpServer({ name: 'example', version: '1.0.0' })
// WRONG: schema defined inside handler — runs after the call is dispatched
// server.tool('search', {}, async (args) => {
// const schema = z.object({ query: z.string() })
// const { query } = schema.parse(args) // too late — dispatch already happened
// return search(query)
// })
// CORRECT: schema is the second argument to server.tool()
// The SDK validates args before calling the handler — if validation fails,
// the handler never executes and the SDK returns a JSON-RPC error
const SearchArgsSchema = z.object({
query: z.string().min(1).max(500).trim(),
page: z.number().int().min(1).max(1000).default(1),
tenantId: z.string().uuid(), // required — see section 4
}).strict() // see section 3
server.tool(
'search_documents',
SearchArgsSchema.shape, // pass the shape, not the schema object
async ({ query, page, tenantId }) => {
// args are typed correctly here — query: string, page: number, tenantId: string
// No additional validation needed before calling the implementation
const results = await searchDocuments({ query, page, tenantId })
return { content: [{ type: 'text', text: JSON.stringify(results) }] }
}
)
// When you need to validate data INSIDE your code (not at the MCP boundary):
function parseWebhookPayload(raw: unknown) {
const result = SearchArgsSchema.safeParse(raw)
if (!result.success) {
// Inspect the ZodError without throwing — appropriate for internal validation
logger.warn({ issues: result.error.issues }, 'invalid webhook payload')
return null
}
return result.data
}
2. anyOf security anti-pattern — prefer discriminated unions
The z.union() combinator (equivalent to JSON Schema's anyOf) widens the set of values that pass validation. z.union([z.string(), z.number()]) accepts any string or any number, which sounds precise, but the union type forces every downstream consumer to handle both types with type narrowing. In practice, developers narrow with typeof val === 'string' ? ... : ... and the number branch is either not implemented, not tested, or implemented with a subtle type coercion that an attacker can exploit by sending a number where a string was expected.
The attack surface of a union grows with each member. z.union([z.string(), z.number(), z.boolean(), z.null()]) is not a more flexible schema — it is a schema that accepts four different types and requires four different handling paths, all of which must be correct for the tool to be secure. In most cases, the real intent is to handle one specific case with some variation, which is better expressed as a discriminated union with a literal type field. The TypeScript compiler then guarantees that every branch is handled and that the type is known within each branch.
import { z } from 'zod'
// ANTI-PATTERN: anyOf union widens attack surface
// Accepts string OR number — downstream must handle both or miss a case
const LooseFilterSchema = z.object({
value: z.union([z.string(), z.number()]), // what does a number mean here?
})
// ANTI-PATTERN: nested anyOf — combines attack surfaces multiplicatively
const VeryLooseSchema = z.object({
id: z.union([z.string().uuid(), z.number().int(), z.null()]),
scope: z.union([z.string(), z.array(z.string())]),
})
// ---
// PREFERRED: discriminated union — type field is a literal, narrowed automatically
const SearchFilterSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
value: z.string().min(1).max(500),
}),
z.object({
type: z.literal('range'),
min: z.number(),
max: z.number(),
}),
z.object({
type: z.literal('exact_id'),
value: z.string().uuid(), // constrained to UUID — not any string
}),
])
type SearchFilter = z.infer<typeof SearchFilterSchema>
function applyFilter(filter: SearchFilter): string {
// TypeScript knows the exact shape in each branch — no runtime surprises
switch (filter.type) {
case 'text':
return `content ILIKE $1` // filter.value is string here
case 'range':
return `value BETWEEN $1 AND $2` // filter.min/max are numbers here
case 'exact_id':
return `id = $1` // filter.value is UUID string here
// TypeScript errors if a new union member is added and not handled
}
}
For JSON Schema users who do not use Zod, the equivalent is oneOf with explicit type fields and if/then/else discrimination, combined with strict required arrays. The MCP SDK accepts raw JSON Schema as the tool parameter definition — if you use raw JSON Schema instead of Zod, add "additionalProperties": false and make your discriminators explicit.
3. additionalProperties: false equivalent in Zod
By default, Zod's z.object() strips unknown keys silently. Pass in { name: 'alice', role: 'admin', __proto__: {} } and the parsed output is { name: 'alice' } with no error. This behavior is convenient for accepting flexible input, but it creates a specific security problem: an attacker can probe your schema by sending extra fields and observing whether they generate validation errors. If no error is returned for any extra field, the attacker learns that extra fields are silently accepted — which is useful information for crafting a more targeted payload.
The more serious risk is mass assignment. If the validated object is later passed to an ORM, query builder, or object constructor that accepts keyword arguments, extra fields that were stripped by Zod during validation may have already been processed before Zod ran — or Zod's output may be spread into a larger object that is passed downstream. The .strict() modifier makes Zod reject any input that contains keys not declared in the schema, which is the exact equivalent of JSON Schema's additionalProperties: false. The error message names the unknown keys, which aids debugging without exposing sensitive data.
import { z } from 'zod'
// WITHOUT .strict() — unknown keys are silently stripped
const LooseUserSchema = z.object({
name: z.string(),
email: z.string().email(),
})
LooseUserSchema.parse({
name: 'Alice',
email: 'alice@example.com',
role: 'admin', // extra key — silently stripped, no error
__proto__: {}, // prototype pollution attempt — silently stripped
})
// Result: { name: 'Alice', email: 'alice@example.com' }
// Attacker learns: extra keys are accepted — probe further
// ---
// WITH .strict() — unknown keys cause a ZodError
const StrictUserSchema = z.object({
name: z.string().min(1).max(128),
email: z.string().email().toLowerCase(),
}).strict()
StrictUserSchema.parse({
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
})
// Throws ZodError: Unrecognized key(s) in object: 'role'
// Attacker learns: extra keys are rejected — schema is hardened
// ---
// For nested objects, .strict() must be applied at each level separately
const CreateRecordSchema = z.object({
title: z.string().min(1).max(256),
metadata: z.object({
tags: z.array(z.string().max(64)).max(20),
source: z.enum(['api', 'import', 'manual']),
}).strict(), // nested .strict() prevents extra metadata keys
}).strict() // outer .strict() prevents extra top-level keys
// Equivalent JSON Schema:
// {
// "type": "object",
// "properties": { "title": {...}, "metadata": { "additionalProperties": false, ... } },
// "additionalProperties": false,
// "required": ["title", "metadata"]
// }
4. Required field enforcement for security-critical parameters
The distinction between required and optional parameters is a security decision when the parameter gates data access. A tenantId field that determines which tenant's records a query returns must be required. If it is optional, a request that omits the field passes validation, the tool handler receives undefined for tenantId, and the query logic must handle the undefined case. In practice, query logic that handles a missing tenant ID either throws a runtime error, returns an empty result set, or — the catastrophic case — runs the query without a tenant filter and returns records from every tenant.
Zod's .optional() and .nullish() modifiers have subtly different semantics. .optional() allows the field to be absent (TypeScript type: T | undefined). .nullish() allows the field to be absent or explicitly set to null (TypeScript type: T | null | undefined). Neither modifier is appropriate for a security-critical parameter — it must be required with no optional modifier. The correct signal to a developer reading the schema is that the absence of .optional() means the field is required and will always be present in validated input.
import { z } from 'zod'
// WRONG: tenantId is optional — query logic must handle undefined
const UnsafeQuerySchema = z.object({
query: z.string(),
tenantId: z.string().uuid().optional(), // can be undefined!
}).strict()
async function unsafeQueryHandler({ query, tenantId }: z.infer<typeof UnsafeQuerySchema>) {
// tenantId is string | undefined here — TypeScript requires you to handle undefined
// Easy to accidentally write:
const results = await db.query(
`SELECT * FROM records WHERE content ILIKE $1 AND tenant_id = $2`,
[query, tenantId] // undefined becomes NULL in pg — may match NULLs or no rows
// If tenant_id is NOT NULL and indexed, this likely returns 0 rows — silent data loss
// If tenant_id allows NULL, this is a data exposure bug
)
}
// ---
// CORRECT: tenantId is required — TypeScript type is string, not string | undefined
const SafeQuerySchema = z.object({
query: z.string().min(1).max(500).trim(),
tenantId: z.string().uuid(), // required — no .optional(), no .nullish()
userId: z.string().uuid(), // required — the authenticated user performing the query
limit: z.number().int().min(1).max(100).default(20),
}).strict()
async function safeQueryHandler({ query, tenantId, userId, limit }: z.infer<typeof SafeQuerySchema>) {
// tenantId and userId are both string here — TypeScript guarantees it
// No defensive undefined checks needed in the query logic
const results = await db.query(
`SELECT id, title, created_at FROM records
WHERE tenant_id = $1 AND content ILIKE $2
ORDER BY created_at DESC LIMIT $3`,
[tenantId, `%${query}%`, limit]
)
return results
}
// .optional() IS appropriate for genuinely optional non-security parameters:
const SearchOptionsSchema = z.object({
query: z.string().min(1).max(500),
tenantId: z.string().uuid(), // required — security boundary
sortField: z.enum(['created_at', 'title', 'updated_at']).optional(), // optional — safe default
ascending: z.boolean().optional(), // optional — safe default
}).strict()
5. Nullable type widening pitfalls
JavaScript and TypeScript treat null and undefined as distinct values, but both represent "absence of a value" in common usage. Zod's .nullable() modifier allows a field to be explicitly set to null — it does not allow undefined. The security problem arises when a field is declared as z.string().nullable() and the downstream code assumes the value is always a string and calls string methods on it. null.trim() throws a TypeError. null.toLowerCase() throws a TypeError. null.includes('admin') throws a TypeError — which is a runtime crash, not a security check.
The more subtle problem is when nullable type widening bypasses a security check that was written for strings. If an authorization check does if (userId.startsWith('svc-')) { grantServiceAccess() } and userId can be null, the check throws before the authorization decision is made, which triggers the fail-closed behavior — but only if the error boundary is in place. Without the error boundary, the null propagates further and the security check is bypassed silently. The fix is to not allow null in security-critical string fields in the first place.
import { z } from 'zod'
// PROBLEM: nullable string — null bypasses downstream string checks
const UnsafeTokenSchema = z.object({
apiToken: z.string().nullable(), // allows null!
operation: z.string(),
}).strict()
function checkTokenScope(token: string | null, requiredScope: string): boolean {
// token is string | null — TypeScript forces you to handle null
// A developer might write:
return token?.includes(requiredScope) ?? false
// ?? false means null token === no permission — seems safe
// But what if the check was written without the TypeScript type?:
// return token.includes(requiredScope) // TypeError at runtime if null
}
// ---
// CORRECT: string fields that gate access must not be nullable
const SafeTokenSchema = z.object({
apiToken: z.string().min(20).max(512), // required string — never null
operation: z.string().min(1).max(100),
}).strict()
// With the above schema, checkTokenScope receives string, not string | null
function checkTokenScopeSafe(token: string, requiredScope: string): boolean {
// token is string — all string methods are safe to call
return token.includes(`:${requiredScope}:`) || token.endsWith(`:${requiredScope}`)
}
// ---
// When is .nullable() appropriate?
// Only for truly nullable data — display fields, optional metadata, user-provided text
const UserProfileSchema = z.object({
userId: z.string().uuid(), // security-critical — never nullable
displayName: z.string().max(128).nullable(), // display only — null means not set
bio: z.string().max(2048).nullable(), // display only — null means not set
avatarUrl: z.string().url().nullable(), // display only — safe to render null as empty
}).strict()
// When you do have a nullable field, handle null explicitly before any string operations:
function renderBio(bio: string | null): string {
if (bio === null) return '' // explicit null check before string operations
return bio.trim().slice(0, 160)
}
// EXTRA PITFALL: z.coerce.string().parse(null) returns the string "null"
// Never use coercion to work around a schema that should reject null
const CoercedSchema = z.object({ id: z.coerce.string() })
CoercedSchema.parse({ id: null })
// Result: { id: "null" } — the string literal "null", not an error!
// This string may match database records or generate misleading audit logs
Audit your MCP server's input validation schemas
SkillAudit checks for anyOf widening, missing .strict(), nullable security-critical fields, and optional parameters that gate data access in MCP server tool definitions.
See pricing