validation.ts•7.23 kB
import { z } from 'zod';
import { createLogger } from './logger';
const logger = createLogger('Validation');
/**
* Custom validation error with additional context
*/
export class ValidationError extends Error {
constructor(
message: string,
public readonly context?: {
toolName?: string;
issues?: z.ZodIssue[];
originalParams?: unknown;
}
) {
super(message);
this.name = 'ValidationError';
}
}
/**
* Type guard for valid JSON Schema objects
*/
function isValidJSONSchema(schema: unknown): schema is Record<string, unknown> {
return typeof schema === 'object' &&
schema !== null &&
'type' in schema;
}
/**
* Schema cache for performance optimization
*/
class SchemaCache {
private static cache = new Map<string, z.ZodSchema>();
private static readonly MAX_SIZE = 100;
static get(key: string): z.ZodSchema | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end for LRU behavior
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
static set(key: string, schema: z.ZodSchema): void {
// Remove existing key if present (for LRU update)
if (this.cache.has(key)) {
this.cache.delete(key);
}
// Evict oldest entries if cache is full (proper LRU eviction)
while (this.cache.size >= this.MAX_SIZE) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
} else {
break; // Safety check
}
}
this.cache.set(key, schema);
}
static clear(): void {
this.cache.clear();
}
static getStats(): { size: number; maxSize: number } {
return { size: this.cache.size, maxSize: this.MAX_SIZE };
}
}
/**
* Convert JSON Schema property to Zod type
*/
function convertPropertyToZod(prop: Record<string, unknown>): z.ZodTypeAny {
if (!prop || typeof prop !== 'object') {
logger.warn('Invalid property schema, defaulting to unknown', { prop });
return z.unknown();
}
const { type, enum: enumValues, items, format } = prop;
// Handle enum constraints
if (enumValues && Array.isArray(enumValues) && enumValues.length > 0) {
if (enumValues.every(v => typeof v === 'string')) {
return z.enum(enumValues as [string, ...string[]]);
}
const literals = enumValues.map(v => z.literal(v));
if (literals.length === 1) {
return literals[0];
}
return z.union([literals[0], literals[1], ...literals.slice(2)]);
}
// Handle primitive types
switch (type) {
case 'string': {
let stringSchema = z.string();
if (format === 'email') stringSchema = stringSchema.email();
if (format === 'uri') stringSchema = stringSchema.url();
return stringSchema;
}
case 'number':
return z.number();
case 'integer':
return z.number().int();
case 'boolean':
return z.boolean();
case 'array':
if (items && typeof items === 'object') {
const itemSchema = convertPropertyToZod(items as Record<string, unknown>);
return z.array(itemSchema);
}
return z.array(z.unknown());
case 'object':
// For nested objects, we'd need recursive handling
// For now, treat as record of unknown values
return z.record(z.unknown());
default:
logger.warn('Unsupported schema type, defaulting to unknown', { type, prop });
return z.unknown();
}
}
/**
* Create Zod schema for MCP server tool registration
* Returns object with property schemas for MCP server
*/
export function createMcpZodSchema(schema: Record<string, unknown>): Record<string, z.ZodTypeAny> {
if (!isValidJSONSchema(schema)) {
logger.warn('Invalid schema provided to createMcpZodSchema', { schema });
return {};
}
if (schema.type !== 'object' || !schema.properties) {
return {};
}
const properties = schema.properties as Record<string, Record<string, unknown>>;
const required = (schema.required as string[]) || [];
const zodSchemaObj: Record<string, z.ZodTypeAny> = {};
try {
Object.entries(properties).forEach(([key, prop]) => {
let zodType = convertPropertyToZod(prop);
// Make optional if not in required array
if (!required.includes(key)) {
zodType = zodType.optional();
}
zodSchemaObj[key] = zodType;
});
} catch (error) {
logger.error('Error creating MCP Zod schema', { error, schema });
return {};
}
return zodSchemaObj;
}
/**
* Create Zod object schema for parameter validation
* Returns complete Zod schema for validation
*/
export function createValidationSchema(schema: Record<string, unknown>): z.ZodSchema {
if (!isValidJSONSchema(schema)) {
logger.warn('Invalid schema provided to createValidationSchema', { schema });
return z.object({});
}
// Create cache key
const cacheKey = JSON.stringify(schema);
// Check cache first
const cached = SchemaCache.get(cacheKey);
if (cached) {
return cached;
}
let zodSchema: z.ZodSchema;
try {
if (schema.type === 'object' && schema.properties) {
const mcpSchema = createMcpZodSchema(schema);
zodSchema = z.object(mcpSchema);
} else {
// Non-object schemas (rare but possible)
zodSchema = z.object({});
}
} catch (error) {
logger.error('Error creating validation schema', { error, schema });
zodSchema = z.object({});
}
// Cache the result
SchemaCache.set(cacheKey, zodSchema);
return zodSchema;
}
/**
* Validate parameters using JSON Schema with comprehensive error handling
*/
export function validateParams<T>(
params: unknown,
schema: Record<string, unknown>,
toolName?: string
): T {
try {
const zodSchema = createValidationSchema(schema);
logger.debug('Validating parameters', {
toolName,
paramsType: typeof params,
schemaType: schema.type
});
const result = zodSchema.parse(params);
logger.debug('Parameter validation successful', { toolName });
return result as T;
} catch (error) {
if (error instanceof z.ZodError) {
const context = toolName ? ` in tool "${toolName}"` : '';
const issues = error.errors.map(err => {
const path = err.path.length > 0 ? err.path.join('.') : 'root';
return `${path}: ${err.message}`;
});
const validationError = new ValidationError(
`Parameter validation failed${context}: ${issues.join(', ')}`,
{
toolName,
issues: error.errors,
originalParams: params
}
);
logger.error('Parameter validation failed', {
toolName,
error: validationError.message,
issues: error.errors,
params: typeof params === 'object' ? 'object' : params
});
throw validationError;
}
logger.error('Unexpected validation error', { error, toolName });
throw error;
}
}
// Legacy exports for backward compatibility
export const createZodSchema = createMcpZodSchema;
export const createZodObjectSchema = createValidationSchema;