@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
474 lines (425 loc) • 11.9 kB
text/typescript
/**
* Schema Validation Utility
* Implements MCP Design Guide Section 2.3 principles for robust tool input validation
*/
import { ErrorHandler } from './error-handler.js';
export interface ValidationRule {
field: string;
type: string;
required?: boolean;
min?: number;
max?: number;
pattern?: string;
enum?: any[];
custom?: (value: any) => boolean | string;
}
export interface SchemaDefinition {
type: 'object';
properties: Record<string, any>;
required?: string[];
additionalProperties?: boolean;
}
export class SchemaValidator {
/**
* Enhanced schema patterns for common MCP tool parameters
*/
static readonly COMMON_SCHEMAS = {
// Identifiers
ID: {
type: 'string',
pattern: '^[a-zA-Z0-9\\-_]+$',
minLength: 1,
maxLength: 50,
description: 'Alphanumeric identifier with hyphens and underscores'
},
UUID: {
type: 'string',
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
description: 'UUID v4 format'
},
// Names and titles
NAME: {
type: 'string',
minLength: 1,
maxLength: 100,
pattern: '^[a-zA-Z0-9\\s\\-_\\.]+$',
description: 'Name with alphanumeric characters, spaces, hyphens, underscores, and periods'
},
TITLE: {
type: 'string',
minLength: 1,
maxLength: 150,
description: 'Title or summary text'
},
// Descriptions
SHORT_DESCRIPTION: {
type: 'string',
minLength: 5,
maxLength: 500,
description: 'Short description text'
},
LONG_DESCRIPTION: {
type: 'string',
minLength: 10,
maxLength: 2000,
description: 'Detailed description text'
},
// Dates and times
DATE: {
type: 'string',
format: 'date',
description: 'Date in YYYY-MM-DD format'
},
DATETIME: {
type: 'string',
format: 'date-time',
description: 'Date and time in ISO 8601 format'
},
// Priority levels
PRIORITY: {
type: 'string',
enum: ['low', 'medium', 'high', 'critical'],
description: 'Priority level'
},
// Status values
AGILE_STATUS: {
type: 'string',
enum: ['planned', 'active', 'completed', 'cancelled'],
description: 'Agile item status'
},
TASK_STATUS: {
type: 'string',
enum: ['todo', 'in_progress', 'review', 'testing', 'done', 'blocked'],
description: 'Task status'
},
// Story points (Fibonacci sequence)
STORY_POINTS: {
type: 'number',
enum: [0, 1, 2, 3, 5, 8, 13, 21],
description: 'Story points using Fibonacci sequence'
},
// Duration constraints
SPRINT_DURATION: {
type: 'number',
minimum: 1,
maximum: 30,
description: 'Sprint duration in days'
},
// Email format
EMAIL: {
type: 'string',
format: 'email',
description: 'Valid email address'
},
// URL format
URL: {
type: 'string',
format: 'uri',
description: 'Valid URL'
},
// Arrays with constraints
TAG_ARRAY: {
type: 'array',
items: {
type: 'string',
minLength: 1,
maxLength: 30,
pattern: '^[a-zA-Z0-9\\-_]+$'
},
maxItems: 10,
uniqueItems: true,
description: 'Array of tags (max 10, alphanumeric with hyphens/underscores)'
},
STRING_ARRAY: {
type: 'array',
items: {
type: 'string',
minLength: 1,
maxLength: 200
},
maxItems: 20,
description: 'Array of strings (max 20 items)'
}
};
/**
* Create enhanced schema for tool definitions
*/
static createToolSchema(fields: Record<string, string | object>, required: string[] = []): SchemaDefinition {
const properties: Record<string, any> = {};
for (const [fieldName, schemaType] of Object.entries(fields)) {
if (typeof schemaType === 'string') {
// Use predefined schema
if (this.COMMON_SCHEMAS[schemaType as keyof typeof this.COMMON_SCHEMAS]) {
properties[fieldName] = { ...this.COMMON_SCHEMAS[schemaType as keyof typeof this.COMMON_SCHEMAS] };
} else {
throw new Error(`Unknown schema type: ${schemaType}`);
}
} else {
// Use custom schema object
properties[fieldName] = schemaType;
}
}
return {
type: 'object',
properties,
required,
additionalProperties: false
};
}
/**
* Validate parameters against schema with detailed error reporting
*/
static validateParameters(
params: any,
schema: SchemaDefinition,
context: { tool: string; module: string }
): void {
if (!params || typeof params !== 'object') {
throw ErrorHandler.createValidationError(
'params',
params,
'must be an object',
context
);
}
// Check required fields
if (schema.required) {
for (const field of schema.required) {
if (!(field in params) || params[field] === undefined || params[field] === null) {
throw ErrorHandler.createValidationError(
field,
params[field],
'is required',
context
);
}
}
}
// Validate each property
for (const [fieldName, value] of Object.entries(params)) {
if (value === undefined || value === null) {
continue; // Skip undefined/null values for optional fields
}
const fieldSchema = schema.properties[fieldName];
if (!fieldSchema && !schema.additionalProperties) {
throw ErrorHandler.createValidationError(
fieldName,
value,
'is not allowed (additional properties forbidden)',
context
);
}
if (fieldSchema) {
this.validateField(fieldName, value, fieldSchema, context);
}
}
}
/**
* Validate individual field against its schema
*/
private static validateField(
fieldName: string,
value: any,
schema: any,
context: { tool: string; module: string }
): void {
// Type validation
if (schema.type) {
if (!this.validateType(value, schema.type)) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must be of type ${schema.type}`,
context
);
}
}
// String validations
if (schema.type === 'string' && typeof value === 'string') {
if (schema.minLength && value.length < schema.minLength) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must be at least ${schema.minLength} characters long`,
context
);
}
if (schema.maxLength && value.length > schema.maxLength) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must be no more than ${schema.maxLength} characters long`,
context
);
}
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must match pattern: ${schema.pattern}`,
context
);
}
if (schema.format) {
this.validateFormat(fieldName, value, schema.format, context);
}
}
// Number validations
if (schema.type === 'number' && typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must be at least ${schema.minimum}`,
context
);
}
if (schema.maximum !== undefined && value > schema.maximum) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must be no more than ${schema.maximum}`,
context
);
}
}
// Array validations
if (schema.type === 'array' && Array.isArray(value)) {
if (schema.minItems && value.length < schema.minItems) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must have at least ${schema.minItems} items`,
context
);
}
if (schema.maxItems && value.length > schema.maxItems) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must have no more than ${schema.maxItems} items`,
context
);
}
if (schema.uniqueItems) {
const uniqueValues = new Set(value);
if (uniqueValues.size !== value.length) {
throw ErrorHandler.createValidationError(
fieldName,
value,
'must contain unique items only',
context
);
}
}
// Validate each array item
if (schema.items) {
value.forEach((item, index) => {
this.validateField(`${fieldName}[${index}]`, item, schema.items, context);
});
}
}
// Enum validation
if (schema.enum && !schema.enum.includes(value)) {
throw ErrorHandler.createValidationError(
fieldName,
value,
`must be one of: ${schema.enum.join(', ')}`,
context
);
}
}
/**
* Validate data type
*/
private static validateType(value: any, expectedType: string): boolean {
switch (expectedType) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'array':
return Array.isArray(value);
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
default:
return true;
}
}
/**
* Validate string formats
*/
private static validateFormat(
fieldName: string,
value: string,
format: string,
context: { tool: string; module: string }
): void {
switch (format) {
case 'email':
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw ErrorHandler.createValidationError(
fieldName,
value,
'must be a valid email address',
context
);
}
break;
case 'uri':
try {
new URL(value);
} catch {
throw ErrorHandler.createValidationError(
fieldName,
value,
'must be a valid URL',
context
);
}
break;
case 'date':
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(value) || isNaN(Date.parse(value))) {
throw ErrorHandler.createValidationError(
fieldName,
value,
'must be a valid date in YYYY-MM-DD format',
context
);
}
break;
case 'date-time':
if (isNaN(Date.parse(value))) {
throw ErrorHandler.createValidationError(
fieldName,
value,
'must be a valid ISO 8601 date-time',
context
);
}
break;
}
}
/**
* Create a decorator for automatic parameter validation
*/
static validateParams(schema: SchemaDefinition) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const params = args[0];
const context = {
tool: propertyKey,
module: target.constructor.name,
};
SchemaValidator.validateParameters(params, schema, context);
return await originalMethod.apply(this, args);
};
return descriptor;
};
}
}