JSON Schema usage patterns in Model Context Protocol servers

mcpjson-schematypescriptzodvalidation
By sko X opus 4.19/19/202510 min read

Model Context Protocol (MCP) servers leverage JSON Schema extensively for defining tool parameters, validating inputs, and ensuring type safety across client-server communications. This comprehensive guide examines practical implementation patterns, drawing from production servers and official specifications to provide developers with actionable insights for building robust MCP implementations.

MCP's TypeScript-first schema architecture

The Model Context Protocol adopts a TypeScript-first approach where the authoritative schema definitions originate in TypeScript code, with JSON Schema automatically generated for broader compatibility. This design philosophy ensures type safety in TypeScript implementations while providing universal JSON Schema validation for other languages.

In the official MCP specification, JSON Schema appears in four critical areas. Tools use inputSchema to define accepted parameters and outputSchema (introduced in spec 2025-06-18) to validate structured responses. Resources employ schemas for template definitions and content validation. Prompts validate argument structures and message formats. The entire protocol message structure itself is defined using JSON Schema, covering request/response formats, notifications, error handling, and capability negotiation.

The current specification has a notable limitation: structuredContent must be an object type, preventing tools from returning arrays or primitive types despite JSON Schema's broader capabilities. Community proposal #834 aims to address this by establishing full JSON Schema 2020-12 support, which would remove the object-only restriction and enable advanced validation compositions.

Zod versus native JSON Schema approaches

Modern MCP implementations overwhelmingly favor Zod for schema definition over native JSON Schema, driven by superior developer experience and automatic type inference. Here's a practical comparison:

// Zod approach - preferred in TypeScript environments
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const weatherSchema = z.object({
  location: z.string().describe("City name or zip code"),
  units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
  includeForcast: z.boolean().optional()
});

server.registerTool("get_weather", {
  description: "Get current weather data",
  inputSchema: weatherSchema
}, async (args) => {
  // TypeScript automatically infers parameter types
  // args.location is string, args.units is "celsius" | "fahrenheit"
  const validated = weatherSchema.parse(args);
  // Implementation with full type safety
});

The native JSON Schema approach, while more verbose, offers language-agnostic compatibility:

// Native JSON Schema approach
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [{
      name: "get_weather",
      description: "Get current weather data",
      inputSchema: {
        type: "object",
        properties: {
          location: {
            type: "string",
            description: "City name or zip code"
          },
          units: {
            type: "string",
            enum: ["celsius", "fahrenheit"],
            default: "celsius"
          }
        },
        required: ["location"],
        additionalProperties: false
      }
    }]
  };
});

The Zod approach provides automatic TypeScript type inference, built-in runtime validation with detailed error messages, excellent IntelliSense support, and zero external dependencies. However, it requires a TypeScript environment and adds a learning curve. Native JSON Schema offers protocol-native compatibility, works across all programming languages, and integrates with existing JSON Schema tooling, but lacks type safety and requires manual validation implementation.

Common schema patterns in MCP tools

Production MCP servers consistently employ several schema patterns that balance flexibility with validation rigor. The most prevalent pattern handles optional parameters with defaults:

const toolSchema = z.object({
  // Required parameters with validation
  query: z.string().min(1).describe("Search query"),
  
  // Optional with default and constraints
  limit: z.number().min(1).max(100).default(10)
    .describe("Maximum results to return"),
  
  // Optional without default
  category: z.string().optional()
    .describe("Filter by category"),
  
  // Nested object with mixed requirements
  options: z.object({
    includeMetadata: z.boolean().default(false),
    sortBy: z.enum(["relevance", "date", "popularity"]).default("relevance")
  }).optional()
});

For complex nested structures, MCP tools often model hierarchical data:

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
  country: z.string().default("US")
});

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  addresses: z.array(addressSchema).min(1),
  preferences: z.record(z.string(), z.any()).optional()
});

Best practices for tool parameter definition

Effective parameter definition requires careful attention to validation, documentation, and error handling. Input sanitization prevents security vulnerabilities:

const secureInputSchema = z.object({
  filename: z.string()
    .regex(/^[a-zA-Z0-9._-]+$/, "Invalid filename characters")
    .refine(name => !name.startsWith('.'), "Hidden files not allowed"),
    
  sqlQuery: z.string()
    .refine(query => {
      const dangerous = ['drop', 'delete', 'truncate'];
      return !dangerous.some(word => 
        query.toLowerCase().includes(word)
      );
    }, "Query contains dangerous operations"),
    
  userInput: z.string()
    .trim()
    .min(1, "Input cannot be empty")
    .max(1000, "Input exceeds maximum length")
    .transform(input => input.replace(/[<>]/g, ""))
});

Rich descriptions enhance LLM understanding:

const documentedSchema = z.object({
  query: z.string()
    .describe("Search query - supports wildcards (*) and exact phrases in quotes"),
    
  dateRange: z.object({
    start: z.string().datetime()
      .describe("Start date in ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ)"),
    end: z.string().datetime()
      .describe("End date in ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ)")
  }).optional()
    .describe("Filter results by date range - both dates must be provided"),
    
  sortBy: z.enum(["relevance", "date", "popularity"])
    .default("relevance")
    .describe("Sort order: relevance (best match), date (newest first), popularity (most viewed)")
});

Advanced JSON Schema features in MCP

While basic schemas suffice for most tools, complex MCP servers leverage advanced JSON Schema features. However, client compatibility varies significantly.

Schema composition with allOf

The allOf keyword enables country-specific validation in address tools:

{
  "type": "object",
  "properties": {
    "street_address": {"type": "string"},
    "country": {"enum": ["US", "Canada", "Netherlands"]}
  },
  "allOf": [
    {
      "if": {"properties": {"country": {"const": "US"}}},
      "then": {"properties": {"postal_code": {"pattern": "[0-9]{5}(-[0-9]{4})?"}}
    },
    {
      "if": {"properties": {"country": {"const": "Canada"}}},
      "then": {"properties": {"postal_code": {"pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]"}}}
    }
  ]
}

Union types with anyOf

Database connection tools use anyOf for flexible configuration:

const connectionSchema = z.union([
  z.object({
    type: z.literal("postgresql"),
    host: z.string(),
    port: z.number(),
    database: z.string(),
    username: z.string(),
    password: z.string()
  }),
  z.object({
    type: z.literal("sqlite"),
    file_path: z.string()
  }),
  z.object({
    type: z.literal("connection_string"),
    connection_string: z.string()
  })
]);

Client limitation workarounds

Different MCP clients have varying JSON Schema support levels. Google agents only support anyOf (not allOf or oneOf), Gemini API doesn't support union types at all, and Cursor limits servers to 40 tools with 60-character combined names. Production servers implement capability detection:

function transformSchemaForClient(schema, clientType) {
  const capabilities = {
    "claude-desktop": ["refs", "unions", "formats"],
    "cursor": ["basic"],
    "google-agents": ["anyof-only"],
    "gemini": ["basic", "no-unions"]
  };
  
  if (!capabilities[clientType].includes("unions")) {
    // Remove anyOf/oneOf, use first variant only
    schema = removeUnions(schema);
  }
  
  if (!capabilities[clientType].includes("refs")) {
    // Inline all $ref references
    schema = inlineReferences(schema);
  }
  
  return schema;
}

Validation strategies and implementation

MCP servers predominantly use Zod for runtime validation rather than traditional JSON Schema validators like AJV. This pattern emerges from official SDKs and production implementations:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const UserFilterSchema = z.object({
  userId: z.string().describe("User ID to filter by"),
  page: z.number().int().positive().optional().default(1),
  limit: z.number().int().positive().optional().default(10)
});

server.registerTool("getUserData", {
  inputSchema: UserFilterSchema
}, async (args) => {
  try {
    const validated = UserFilterSchema.parse(args);
    // Process with type safety
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  } catch (error) {
    if (error instanceof z.ZodError) {
      const details = error.errors.map(e => 
        `${e.path.join('.')}: ${e.message}`
      ).join(', ');
      
      return {
        content: [{ type: "text", text: `Validation failed: ${details}` }],
        isError: true
      };
    }
    throw error;
  }
});

For performance optimization, cache compiled validators:

const validatorCache = new Map<string, z.ZodSchema>();

function getValidator(toolName: string, schema: z.ZodSchema) {
  if (!validatorCache.has(toolName)) {
    validatorCache.set(toolName, schema);
  }
  return validatorCache.get(toolName)!;
}

Type generation from JSON Schema

When working with existing JSON Schema specifications, MCP servers use json-schema-to-typescript for generating TypeScript interfaces:

import { compile } from 'json-schema-to-typescript';

const schema = {
  title: "MCPToolInput",
  type: "object",
  properties: {
    query: { type: "string" },
    filters: {
      type: "array",
      items: { type: "string" }
    },
    options: {
      type: "object",
      properties: {
        timeout: { type: "number" },
        retry: { type: "boolean" }
      }
    }
  },
  required: ["query"],
  additionalProperties: false
};

const typescript = await compile(schema, 'MCPToolInput', {
  bannerComment: '/* Auto-generated MCP tool types */',
  unreachableDefinitions: true // Important for schemas with $refs
});

// Generates:
// export interface MCPToolInput {
//   query: string;
//   filters?: string[];
//   options?: {
//     timeout?: number;
//     retry?: boolean;
//   };
// }

For bidirectional type safety, define types first then derive schemas:

interface CreateUserRequest {
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest'])
}) satisfies z.ZodType<CreateUserRequest>;

// Verify type compatibility at compile time
type SchemaType = z.infer<typeof createUserSchema>;
const _typeCheck: SchemaType extends CreateUserRequest ? true : false = true;

JSON Schema version compatibility

The MCP specification currently lacks explicit JSON Schema version declaration, causing validation inconsistencies across clients. Different implementations assume different versions - some use Draft-07, others attempt 2020-12 support. This version ambiguity leads to validation failures and runtime errors.

Community proposal #834 aims to establish JSON Schema 2020-12 as the default dialect with explicit $schema field support. This would enable advanced validation compositions (anyOf, oneOf, allOf), unevaluatedProperties for stricter validation, and removal of the object-only restriction for structured content.

Until official 2020-12 support arrives, developers should use conservative schema features that work across all clients, test schemas against multiple MCP client implementations, and prepare migration paths for future compatibility.

Error handling and validation patterns

MCP defines a structured error response format that all tools should follow:

interface MCPErrorResponse {
  isError: true;
  content: Array<{
    type: "text";
    text: string;
  }>;
}

function handleValidationError(error: ZodError): MCPErrorResponse {
  const errorDetails = error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
    code: err.code
  }));

  const userFriendlyMessage = `Validation failed on ${errorDetails.length} field(s):\n${
    errorDetails.map(detail => `- ${detail.field}: ${detail.message}`).join('\n')
  }`;

  return {
    isError: true,
    content: [{
      type: "text",
      text: userFriendlyMessage
    }]
  };
}

For production resilience, implement circuit breakers for external dependencies:

class CircuitBreaker {
  private failures = 0;
  private state: 'closed' | 'open' | 'half-open' = 'closed';
  
  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      throw new Error('Service temporarily unavailable');
    }
    
    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  private onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }
  
  private onFailure() {
    this.failures++;
    if (this.failures >= 3) {
      this.state = 'open';
      setTimeout(() => this.state = 'half-open', 60000);
    }
  }
}

Real-world implementation examples

Production MCP servers demonstrate sophisticated schema patterns. The GitHub MCP server uses complex nested schemas with union types:

{
  "name": "create_pull_request",
  "inputSchema": {
    "type": "object",
    "properties": {
      "repo": {"type": "string"},
      "title": {"type": "string"},
      "body": {"type": "string"},
      "reviewers": {
        "anyOf": [
          {"type": "array", "items": {"type": "string"}},
          {"type": "null"}
        ]
      }
    },
    "required": ["repo", "title"]
  }
}

The Ultimate MCP server implements multi-provider routing with dynamic schemas:

const multiProviderSchema = z.object({
  prompt: z.string(),
  providers: z.array(z.object({
    provider: z.enum(["google", "anthropic", "gemini", "deepseek"]),
    model: z.string(),
    temperature: z.number().min(0).max(2).optional()
  })).min(1),
  commonParameters: z.record(z.string(), z.any()).optional()
});

Developer tips and recommendations

For schema design: Start with Zod for TypeScript projects to leverage automatic type inference. Use descriptive field names and comprehensive descriptions to help LLMs understand parameter purposes. Implement proper validation with meaningful error messages. Provide sensible defaults where appropriate.

For validation: Prefer Zod over AJV for better TypeScript integration. Implement request-time validation with comprehensive error reporting. Cache compiled validators for performance. Use dynamic schema injection for context-aware validation.

For type safety: Define schemas first, then derive TypeScript types. Use satisfies operator to ensure bidirectional type compatibility. Integrate type generation into build pipelines. Implement runtime type checking alongside compile-time safety.

For error handling: Always return structured error responses with the isError flag. Provide clear, actionable error messages. Implement circuit breakers for external dependencies. Handle partial failures gracefully. Log full error details server-side while sanitizing client messages.

For production deployment: Test schemas against multiple MCP clients. Implement client capability detection. Provide fallback schemas for limited clients. Monitor validation failure rates. Document schema requirements clearly.

The Model Context Protocol's JSON Schema integration provides a robust foundation for building type-safe, validated tool interfaces. While current limitations around version compatibility and client support present challenges, the ecosystem is rapidly maturing. By following these patterns and best practices, developers can create MCP servers that are both powerful and resilient, ready to evolve with the protocol's future enhancements.