NitroStack Logo
/sdk
/typescript
/validation

Validation Guide

Overview

In the Model Context Protocol (MCP), input schemas serve a dual role:

  1. Model Understanding: AI clients convert tool schemas into function definitions for LLMs. Field names, types, descriptions, and defaults directly influence model behavior.
  2. Runtime Security: Before tool handlers execute, NitroStack validates incoming request arguments against the Zod schema, ensuring invalid payloads are rejected before reaching business logic.

Schema Definition with Zod

NitroStack bundles Zod out of the box via @nitrostack/core:

Typescript
import { ToolDecorator as Tool, z, ExecutionContext } from '@nitrostack/core';

// 1. Define the schema
export const AddBookSchema = z.object({
  title: z.string().min(1).max(200).describe('Title of the book'),
  author: z.string().min(1).max(100).describe('Primary author name'),
  isbn: z.string().regex(/^(97(8|9))?\d{9}(\d|X)$/, 'Invalid ISBN-10 or ISBN-13 format')
    .describe('Standard ISBN identifier'),
  genre: z.enum(['fiction', 'non-fiction', 'science', 'history', 'biography'])
    .describe('Book category'),
  copiesAvailable: z.number().int().min(1).default(1)
    .describe('Number of physical copies in inventory'),
  tags: z.array(z.string()).default([]).describe('Search tags and keywords'),
});

// 2. Infer the TypeScript type
export type AddBookInput = z.infer<typeof AddBookSchema>;

export class BookTools {
  @Tool({
    name: 'add_book',
    description: 'Add a new book to the library catalog',
    inputSchema: AddBookSchema,
  })
  async addBook(input: AddBookInput, ctx: ExecutionContext) {
    ctx.logger.info('Adding validated book', { isbn: input.isbn });
    // input is strongly typed and runtime validated
    return { success: true, book: input };
  }
}

AI Schema Design Best Practices

Designing schemas for LLMs requires specific conventions to maximize model comprehension:

1. Always Use .describe()

LLMs rely on .describe() docstrings to understand what arguments represent. Without descriptions, models must guess parameter semantics:

Typescript
// Good: Clear intent
z.string().describe('City name or IATA 3-letter airport code (e.g., "SFO")')

// Bad: No context
z.string()

2. Provide Defaults for Optional Parameters

Providing .default(...) simplifies tool invocation for models and prevents undefined checking in handlers:

Typescript
z.number().int().min(1).max(100).default(10).describe('Max results to return')

3. Use .enum() for Fixed Sets

Avoid open-ended strings when only specific values are acceptable:

Typescript
z.enum(['asc', 'desc']).default('desc').describe('Sort direction')

4. Prefer Flat Schemas over Deeply Nested Objects

Deep nesting creates friction for LLMs and increases token consumption. Flatten parameters where possible:

Typescript
// Recommended: Flat arguments
z.object({
  query: z.string().describe('Search keyword'),
  minPrice: z.number().optional().describe('Minimum price filter'),
  maxPrice: z.number().optional().describe('Maximum price filter'),
})

// Discouraged: Unnecessarily nested
z.object({
  search: z.object({
    filters: z.object({
      priceRange: z.object({ min: z.number(), max: z.number() })
    })
  })
})

Advanced Validation Patterns

Custom Refinements with .refine()

Use .refine() to enforce multi-field dependencies or external rules:

Typescript
export const DateRangeSchema = z.object({
  startDate: z.string().datetime().describe('Start date (ISO-8601)'),
  endDate: z.string().datetime().describe('End date (ISO-8601)'),
}).refine(
  (data) => new Date(data.endDate) > new Date(data.startDate),
  { message: 'endDate must occur after startDate', path: ['endDate'] }
);

Transformations with .transform()

Transform input data during the validation step:

Typescript
export const SearchSchema = z.object({
  query: z.string()
    .trim()
    .toLowerCase()
    .describe('Search query string (automatically normalized)'),
});

Combining Validation with NitroStack Pipes

While Zod validates raw types, Pipes (UsePipes) normalize or augment input before reaching the handler:

Typescript
import { PipeInterface, ArgumentMetadata, Injectable } from '@nitrostack/core';

@Injectable()
export class IsbnNormalizePipe implements PipeInterface {
  transform(value: unknown, _metadata: ArgumentMetadata) {
    if (value && typeof value === 'object' && 'isbn' in value) {
      const record = value as { isbn: string };
      // Strip hyphens and spaces
      record.isbn = record.isbn.replace(/[-\s]/g, '');
    }
    return value;
  }
}

Apply the pipe alongside the schema:

Typescript
import { ToolDecorator as Tool, UsePipes, ExecutionContext, z } from '@nitrostack/core';
import { IsbnNormalizePipe } from './pipes/isbn-normalize.pipe.js';

export class LibraryTools {
  @UsePipes(IsbnNormalizePipe)
  @Tool({
    name: 'lookup_isbn',
    description: 'Lookup book metadata by ISBN',
    inputSchema: z.object({ isbn: z.string().describe('ISBN-10 or ISBN-13') }),
  })
  async lookupIsbn(input: { isbn: string }, ctx: ExecutionContext) {
    // input.isbn is already normalized
    return this.catalogService.findByIsbn(input.isbn);
  }
}

Error Handling & Exception Mapping

When validation fails, NitroStack catches the error and rejects the request. Using an Exception Filter (@UseFilters), you can map ZodError into structured client responses:

Typescript
import { ExceptionFilter, ExceptionFilterInterface, ExecutionContext } from '@nitrostack/core';
import { ZodError } from 'zod';

@ExceptionFilter()
export class ValidationExceptionFilter implements ExceptionFilterInterface {
  catch(exception: unknown, context: ExecutionContext) {
    if (exception instanceof ZodError) {
      context.logger.warn('Validation error encountered', { issues: exception.issues });
      return {
        isError: true,
        error: 'Validation failed',
        details: exception.issues.map((i) => ({
          field: i.path.join('.'),
          message: i.message,
        })),
      };
    }
    throw exception;
  }
}