Skip to main content

Schema Validation

LeanMCP uses class-based schemas with TypeScript decorators to provide compile-time type safety and runtime validation.

Class-Based Schemas

Define input/output schemas as TypeScript classes with property decorators:
import { SchemaConstraint, Optional } from '@leanmcp/core';

class UserInput {
  @SchemaConstraint({
    description: 'User email address',
    format: 'email'
  })
  email!: string;

  @Optional()
  @SchemaConstraint({
    description: 'User age',
    minimum: 18,
    maximum: 120
  })
  age?: number;
}
Benefits:
  • Type safety at compile time
  • Automatic JSON Schema generation
  • No duplication between TypeScript types and schemas
  • IntelliSense support in IDEs

@SchemaConstraint Decorator

The @SchemaConstraint decorator adds JSON Schema validation rules to properties.

Type Signature

From packages/core/src/schema-generator.ts:94-107:
export function SchemaConstraint(constraints: {
  minLength?: number;
  maxLength?: number;
  minimum?: number;
  maximum?: number;
  pattern?: string;
  enum?: any[];
  description?: string;
  default?: any;
  type?: string;
}): PropertyDecorator

String Constraints

class TextInput {
  @SchemaConstraint({
    description: 'Username',
    minLength: 3,
    maxLength: 20,
    pattern: '^[a-zA-Z0-9_]+$'
  })
  username!: string;

  @SchemaConstraint({
    description: 'Email address',
    format: 'email',
    minLength: 5
  })
  email!: string;

  @SchemaConstraint({
    description: 'User role',
    enum: ['admin', 'user', 'guest'],
    default: 'user'
  })
  role!: string;

  @SchemaConstraint({
    description: 'Website URL',
    format: 'uri',
    pattern: '^https://'
  })
  website!: string;
}
Available Constraints:
  • minLength - Minimum string length
  • maxLength - Maximum string length
  • pattern - Regular expression (as string)
  • format - Format hint ('email', 'uri', 'date-time', etc.)
  • enum - List of allowed values
  • description - Human-readable description
  • default - Default value if not provided

Number Constraints

class RangeInput {
  @SchemaConstraint({
    description: 'Temperature in Celsius',
    minimum: -273.15,
    maximum: 1000
  })
  temperature!: number;

  @SchemaConstraint({
    description: 'Percentage',
    minimum: 0,
    maximum: 100
  })
  percentage!: number;

  @SchemaConstraint({
    description: 'Port number',
    minimum: 1024,
    maximum: 65535,
    default: 8080
  })
  port!: number;

  @SchemaConstraint({
    description: 'Rating',
    minimum: 1,
    maximum: 5,
    enum: [1, 2, 3, 4, 5]
  })
  rating!: number;
}
Available Constraints:
  • minimum - Minimum value (inclusive)
  • maximum - Maximum value (inclusive)
  • enum - List of allowed values
  • description - Human-readable description
  • default - Default value if not provided

Array Constraints

class ListInput {
  @SchemaConstraint({
    description: 'List of tags',
    minItems: 1,
    maxItems: 10
  })
  tags!: string[];

  @SchemaConstraint({
    description: 'Product IDs',
    uniqueItems: true
  })
  productIds!: number[];

  @SchemaConstraint({
    description: 'Categories',
    items: { type: 'string', enum: ['tech', 'business', 'lifestyle'] }
  })
  categories!: string[];
}
Available Constraints:
  • minItems - Minimum array length
  • maxItems - Maximum array length
  • uniqueItems - Whether items must be unique
  • items - Schema for array items
  • description - Human-readable description
From packages/core/src/schema-generator.ts:247-249, arrays always include an items schema. If not specified, it defaults to { type: 'string' }.

Boolean Constraints

class SettingsInput {
  @SchemaConstraint({
    description: 'Enable notifications',
    default: true
  })
  notifications!: boolean;

  @SchemaConstraint({
    description: 'Dark mode enabled'
  })
  darkMode!: boolean;
}

@Optional Decorator

The @Optional decorator marks a property as optional in the generated JSON Schema.

Type Signature

From packages/core/src/schema-generator.ts:85-89:
export function Optional(): PropertyDecorator {
  return (target, propertyKey) => {
    Reflect.defineMetadata('optional', true, target, propertyKey);
  };
}

Usage

class SearchInput {
  // Required field (no @Optional)
  @SchemaConstraint({ description: 'Search query' })
  query!: string;

  // Optional field
  @Optional()
  @SchemaConstraint({ 
    description: 'Result limit',
    minimum: 1,
    maximum: 100,
    default: 10
  })
  limit?: number;

  // Optional with enum
  @Optional()
  @SchemaConstraint({
    description: 'Sort order',
    enum: ['asc', 'desc'],
    default: 'asc'
  })
  sortOrder?: string;
}
Properties without @Optional() are marked as required in the JSON Schema. Always use @Optional() for optional properties, even if TypeScript marks them with ?.

Schema Generation

LeanMCP automatically converts class definitions to JSON Schema.

How It Works

From packages/core/src/schema-generator.ts:143-263, the schema generator:
  1. Fast Path: Uses pre-computed metadata from leanmcp build (production)
  2. Slow Path: Parses TypeScript types using ts-morph (development)
  3. Extracts property metadata using reflect-metadata
  4. Applies constraints from @SchemaConstraint
  5. Marks optional fields from @Optional
// Input class
class AnalyzeSentimentInput {
  @SchemaConstraint({ description: 'Text to analyze', minLength: 1 })
  text!: string;

  @Optional()
  @SchemaConstraint({ 
    description: 'Language',
    enum: ['en', 'es', 'fr'],
    default: 'en'
  })
  language?: string;
}

// Generated JSON Schema
{
  "type": "object",
  "properties": {
    "text": {
      "type": "string",
      "description": "Text to analyze",
      "minLength": 1
    },
    "language": {
      "type": "string",
      "description": "Language",
      "enum": ["en", "es", "fr"],
      "default": "en"
    }
  },
  "required": ["text"]
}

Type Inference

LeanMCP infers JSON Schema types from TypeScript types:
TypeScript TypeJSON Schema Type
string"string"
number"number"
boolean"boolean"
string[]{"type": "array", "items": {"type": "string"}}
number[]{"type": "array", "items": {"type": "number"}}
object"object"

Real-World Examples

Sentiment Analysis Input

From examples/basic-sentiment-tool/mcp/sentiment/index.ts:7-21:
class AnalyzeSentimentInput {
  @SchemaConstraint({
    description: 'Text to analyze',
    minLength: 1
  })
  text!: string;
  
  @Optional()
  @SchemaConstraint({
    description: 'Language code',
    enum: ['en', 'es', 'fr', 'de'],
    default: 'en'
  })
  language?: string;
}

Sentiment Analysis Output

From examples/basic-sentiment-tool/mcp/sentiment/index.ts:23-42:
class AnalyzeSentimentOutput {
  @SchemaConstraint({
    enum: ['positive', 'negative', 'neutral']
  })
  sentiment!: string;
  
  @SchemaConstraint({
    minimum: -1,
    maximum: 1
  })
  score!: number;
  
  @SchemaConstraint({
    minimum: 0,
    maximum: 1
  })
  confidence!: number;
  
  language!: string;
}

Slack Message Input

From examples/slack-with-auth/mcp/slack/index.ts:9-27:
class SendMessageInput {
  @SchemaConstraint({
    description: 'Slack channel ID or name (e.g., #general)',
    minLength: 1
  })
  channel!: string;

  @SchemaConstraint({
    description: 'Message text to send',
    minLength: 1
  })
  text!: string;

  @Optional()
  @SchemaConstraint({
    description: 'Thread timestamp to reply to'
  })
  threadTs?: string;
}

Channel Creation Input

From examples/slack-with-auth/mcp/slack/index.ts:63-83:
class CreateChannelInput {
  @SchemaConstraint({
    description: 'Channel name (without #)',
    minLength: 1,
    pattern: '^[a-z0-9-_]+$'
  })
  name!: string;

  @Optional()
  @SchemaConstraint({
    description: 'Channel description'
  })
  description?: string;

  @Optional()
  @SchemaConstraint({
    description: 'Whether the channel should be private',
    default: false
  })
  isPrivate?: boolean;
}

Advanced Patterns

Nested Objects

class Address {
  @SchemaConstraint({ description: 'Street address' })
  street!: string;

  @SchemaConstraint({ description: 'City' })
  city!: string;

  @SchemaConstraint({ 
    description: 'Postal code',
    pattern: '^\\d{5}$'
  })
  zip!: string;
}

class UserProfile {
  @SchemaConstraint({ description: 'User name' })
  name!: string;

  @SchemaConstraint({ description: 'User address' })
  address!: Address; // Nested object
}

Conditional Validation

class PaymentInput {
  @SchemaConstraint({
    description: 'Payment method',
    enum: ['card', 'bank', 'paypal']
  })
  method!: string;

  @Optional()
  @SchemaConstraint({
    description: 'Card number (required for card payments)',
    pattern: '^\\d{16}$'
  })
  cardNumber?: string;

  @Optional()
  @SchemaConstraint({
    description: 'Bank account (required for bank payments)',
    pattern: '^\\d{10,12}$'
  })
  bankAccount?: string;
}

Complex Arrays

class Item {
  @SchemaConstraint({ description: 'Item ID' })
  id!: string;

  @SchemaConstraint({ 
    description: 'Quantity',
    minimum: 1
  })
  quantity!: number;
}

class OrderInput {
  @SchemaConstraint({
    description: 'Order items',
    minItems: 1,
    maxItems: 50
  })
  items!: Item[];

  @Optional()
  @SchemaConstraint({
    description: 'Discount codes',
    maxItems: 3
  })
  discountCodes?: string[];
}

Format Strings

Common format values for string constraints:
FormatDescriptionExample
'email'Email address[email protected]
'uri'URI/URLhttps://example.com
'date-time'ISO 8601 date-time2024-01-15T10:30:00Z
'date'ISO 8601 date2024-01-15
'time'ISO 8601 time10:30:00
'uuid'UUID550e8400-e29b-41d4-a716-446655440000
'hostname'Hostnameexample.com
'ipv4'IPv4 address192.168.1.1
'ipv6'IPv6 address2001:0db8::1
class ContactInput {
  @SchemaConstraint({ description: 'Email', format: 'email' })
  email!: string;

  @SchemaConstraint({ description: 'Website', format: 'uri' })
  website!: string;

  @SchemaConstraint({ description: 'Birth date', format: 'date' })
  birthDate!: string;

  @SchemaConstraint({ description: 'User ID', format: 'uuid' })
  userId!: string;
}

Pattern Examples

Common regex patterns for validation:
class ValidationExamples {
  @SchemaConstraint({
    description: 'Alphanumeric only',
    pattern: '^[a-zA-Z0-9]+$'
  })
  alphanumeric!: string;

  @SchemaConstraint({
    description: 'Phone number (US)',
    pattern: '^\\+?1?\\d{10}$'
  })
  phone!: string;

  @SchemaConstraint({
    description: 'Hex color code',
    pattern: '^#[0-9A-Fa-f]{6}$'
  })
  color!: string;

  @SchemaConstraint({
    description: 'Slack channel name',
    pattern: '^[a-z0-9-_]+$'
  })
  channelName!: string;

  @SchemaConstraint({
    description: 'Version number',
    pattern: '^\\d+\\.\\d+\\.\\d+$'
  })
  version!: string;
}
In pattern strings, backslashes must be escaped: use \\d instead of \d, \\s instead of \s, etc.

Best Practices

  • description on every property
  • @Optional() for optional fields
  • default values for optional fields when appropriate
  • Validation constraints (minLength, minimum, etc.)
  • enum for fields with limited choices
Type Safety Tips:
  • Use ! for required fields: name!: string
  • Use ? for optional fields: age?: number
  • Combine both: @Optional() decorator + ? operator
  • Let TypeScript infer types when possible
  • Use strict compiler options for maximum safety

Common Mistakes

Missing @Optional

// ❌ Wrong: Optional in TypeScript but required in schema
class WrongInput {
  name!: string;
  age?: number; // Missing @Optional decorator
}

// ✅ Correct: Properly marked as optional
class CorrectInput {
  name!: string;
  
  @Optional()
  age?: number;
}

Missing Description

// ❌ Wrong: No description
@SchemaConstraint({ minLength: 1 })
name!: string;

// ✅ Correct: Clear description
@SchemaConstraint({ description: 'User full name', minLength: 1 })
name!: string;

Incorrect Pattern Escaping

// ❌ Wrong: Single backslash
@SchemaConstraint({ pattern: '^\d{3}$' })
code!: string;

// ✅ Correct: Double backslash
@SchemaConstraint({ pattern: '^\\d{3}$' })
code!: string;

Next Steps

Build docs developers (and LLMs) love