UNPKG

voyage-and-consumption-mcp-server

Version:

Voyage and consumption management server handling vessel voyages, fuel consumption, performance monitoring, and operational data with ERP access for data extraction

565 lines 21.7 kB
import { logger } from '../utils/logger.js'; import { toolDefinitions } from '../tools/schema.js'; import { validateIMO } from '../utils/imo-validator.js'; export class ValidationException extends Error { constructor(errors, message = 'Validation failed') { super(message); this.errors = errors; this.name = 'ValidationException'; } } export class ValidationMiddleware { constructor() { this.toolSchemas = new Map(); this.initializeSchemas(); } initializeSchemas() { for (const tool of toolDefinitions) { this.toolSchemas.set(tool.name, tool.inputSchema); } } /** * Validates tool input arguments against the tool's schema * @param toolName - Name of the tool * @param args - Input arguments to validate * @returns Validated and sanitized arguments * @throws ValidationException if validation fails */ validateToolInput(toolName, args) { const errors = []; // Check if tool exists const schema = this.toolSchemas.get(toolName); if (!schema) { throw new ValidationException([{ field: 'tool', message: `Unknown tool: ${toolName}`, code: 'UNKNOWN_TOOL' }]); } // Validate against schema const validatedArgs = this.validateAgainstSchema(schema, args, errors); // Tool-specific validation this.performToolSpecificValidation(toolName, validatedArgs, errors); // Throw if there are validation errors if (errors.length > 0) { logger.warn(`Validation failed for tool ${toolName}`, { errors, args }); throw new ValidationException(errors, `Validation failed for tool ${toolName}`); } logger.debug(`Successfully validated input for tool ${toolName}`, { args: validatedArgs }); return validatedArgs; } /** * Validates arguments against a JSON schema */ validateAgainstSchema(schema, args, errors) { const validatedArgs = {}; // Check required fields if (schema.required) { for (const field of schema.required) { if (args[field] === undefined || args[field] === null) { errors.push({ field, message: `Required field '${field}' is missing`, code: 'REQUIRED_FIELD_MISSING' }); } } } // Validate and sanitize each property if (schema.properties) { for (const [fieldName, fieldSchema] of Object.entries(schema.properties)) { const fieldSchemaTyped = fieldSchema; const value = args[fieldName]; // Skip validation for undefined optional fields if (value === undefined && (!schema.required || !schema.required.includes(fieldName))) { continue; } // Validate field const validatedValue = this.validateField(fieldName, value, fieldSchemaTyped, errors); if (validatedValue !== undefined) { validatedArgs[fieldName] = validatedValue; } } } // Check for additional properties if not allowed if (schema.additionalProperties === false) { for (const key of Object.keys(args)) { if (!schema.properties || !schema.properties[key]) { errors.push({ field: key, message: `Additional property '${key}' is not allowed`, code: 'ADDITIONAL_PROPERTY_NOT_ALLOWED' }); } } } return validatedArgs; } /** * Validates a single field against its schema */ validateField(fieldName, value, fieldSchema, errors) { // Type validation const validatedValue = this.validateType(fieldName, value, fieldSchema, errors); if (validatedValue === undefined) { return undefined; } // Format validation if (fieldSchema.format) { this.validateFormat(fieldName, validatedValue, fieldSchema.format, errors); } // Enum validation if (fieldSchema.enum) { if (!fieldSchema.enum.includes(validatedValue)) { errors.push({ field: fieldName, message: `Value '${validatedValue}' is not in allowed enum values: ${fieldSchema.enum.join(', ')}`, value: validatedValue, code: 'ENUM_VALIDATION_FAILED' }); } } // Range validation for numbers if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') { this.validateNumberRange(fieldName, validatedValue, fieldSchema, errors); } // Length validation for strings if (fieldSchema.type === 'string') { this.validateStringConstraints(fieldName, validatedValue, fieldSchema, errors); } // Array validation if (fieldSchema.type === 'array') { this.validateArray(fieldName, validatedValue, fieldSchema, errors); } // Object validation if (fieldSchema.type === 'object') { return this.validateAgainstSchema(fieldSchema, validatedValue, errors); } return validatedValue; } /** * Validates and converts types */ validateType(fieldName, value, fieldSchema, errors) { const expectedType = fieldSchema.type; switch (expectedType) { case 'string': if (typeof value !== 'string') { // Try to convert to string if (value !== null && value !== undefined) { return String(value); } errors.push({ field: fieldName, message: `Expected string but got ${typeof value}`, value, code: 'TYPE_MISMATCH' }); return undefined; } return value; case 'number': if (typeof value === 'number') { if (isNaN(value) || !isFinite(value)) { errors.push({ field: fieldName, message: `Invalid number: ${value}`, value, code: 'INVALID_NUMBER' }); return undefined; } return value; } if (typeof value === 'string') { const parsed = parseFloat(value); if (isNaN(parsed) || !isFinite(parsed)) { errors.push({ field: fieldName, message: `Cannot convert '${value}' to number`, value, code: 'NUMBER_CONVERSION_FAILED' }); return undefined; } return parsed; } errors.push({ field: fieldName, message: `Expected number but got ${typeof value}`, value, code: 'TYPE_MISMATCH' }); return undefined; case 'integer': if (typeof value === 'number') { if (!Number.isInteger(value)) { errors.push({ field: fieldName, message: `Expected integer but got ${value}`, value, code: 'INTEGER_EXPECTED' }); return undefined; } return value; } if (typeof value === 'string') { const parsed = parseInt(value, 10); if (isNaN(parsed) || !isFinite(parsed)) { errors.push({ field: fieldName, message: `Cannot convert '${value}' to integer`, value, code: 'INTEGER_CONVERSION_FAILED' }); return undefined; } return parsed; } errors.push({ field: fieldName, message: `Expected integer but got ${typeof value}`, value, code: 'TYPE_MISMATCH' }); return undefined; case 'boolean': if (typeof value === 'boolean') { return value; } if (typeof value === 'string') { const lowerValue = value.toLowerCase(); if (lowerValue === 'true' || lowerValue === '1') return true; if (lowerValue === 'false' || lowerValue === '0') return false; } errors.push({ field: fieldName, message: `Expected boolean but got ${typeof value}`, value, code: 'TYPE_MISMATCH' }); return undefined; case 'array': if (!Array.isArray(value)) { errors.push({ field: fieldName, message: `Expected array but got ${typeof value}`, value, code: 'TYPE_MISMATCH' }); return undefined; } return value; case 'object': if (typeof value !== 'object' || value === null || Array.isArray(value)) { errors.push({ field: fieldName, message: `Expected object but got ${typeof value}`, value, code: 'TYPE_MISMATCH' }); return undefined; } return value; default: return value; } } /** * Validates format constraints */ validateFormat(fieldName, value, format, errors) { switch (format) { case 'date': if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { errors.push({ field: fieldName, message: `Invalid date format. Expected YYYY-MM-DD but got '${value}'`, value, code: 'INVALID_DATE_FORMAT' }); return; } // Check if date is valid const date = new Date(value); if (isNaN(date.getTime())) { errors.push({ field: fieldName, message: `Invalid date: '${value}'`, value, code: 'INVALID_DATE' }); } break; case 'date-time': if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) { errors.push({ field: fieldName, message: `Invalid datetime format. Expected YYYY-MM-DDTHH:MM:SS but got '${value}'`, value, code: 'INVALID_DATETIME_FORMAT' }); return; } // Check if datetime is valid const datetime = new Date(value); if (isNaN(datetime.getTime())) { errors.push({ field: fieldName, message: `Invalid datetime: '${value}'`, value, code: 'INVALID_DATETIME' }); } break; case 'uri': try { new URL(value); } catch (e) { errors.push({ field: fieldName, message: `Invalid URI format: '${value}'`, value, code: 'INVALID_URI' }); } break; } } /** * Validates number range constraints */ validateNumberRange(fieldName, value, fieldSchema, errors) { if (fieldSchema.minimum !== undefined && value < fieldSchema.minimum) { errors.push({ field: fieldName, message: `Value ${value} is below minimum ${fieldSchema.minimum}`, value, code: 'BELOW_MINIMUM' }); } if (fieldSchema.maximum !== undefined && value > fieldSchema.maximum) { errors.push({ field: fieldName, message: `Value ${value} is above maximum ${fieldSchema.maximum}`, value, code: 'ABOVE_MAXIMUM' }); } } /** * Validates string constraints */ validateStringConstraints(fieldName, value, fieldSchema, errors) { if (fieldSchema.minLength !== undefined && value.length < fieldSchema.minLength) { errors.push({ field: fieldName, message: `String length ${value.length} is below minimum ${fieldSchema.minLength}`, value, code: 'BELOW_MIN_LENGTH' }); } if (fieldSchema.maxLength !== undefined && value.length > fieldSchema.maxLength) { errors.push({ field: fieldName, message: `String length ${value.length} is above maximum ${fieldSchema.maxLength}`, value, code: 'ABOVE_MAX_LENGTH' }); } if (fieldSchema.pattern) { const regex = new RegExp(fieldSchema.pattern); if (!regex.test(value)) { errors.push({ field: fieldName, message: `String '${value}' does not match pattern ${fieldSchema.pattern}`, value, code: 'PATTERN_MISMATCH' }); } } } /** * Validates array constraints */ validateArray(fieldName, value, fieldSchema, errors) { if (fieldSchema.minItems !== undefined && value.length < fieldSchema.minItems) { errors.push({ field: fieldName, message: `Array length ${value.length} is below minimum ${fieldSchema.minItems}`, value, code: 'BELOW_MIN_ITEMS' }); } if (fieldSchema.maxItems !== undefined && value.length > fieldSchema.maxItems) { errors.push({ field: fieldName, message: `Array length ${value.length} is above maximum ${fieldSchema.maxItems}`, value, code: 'ABOVE_MAX_ITEMS' }); } // Validate array items if (fieldSchema.items) { for (let i = 0; i < value.length; i++) { const itemErrors = []; this.validateField(`${fieldName}[${i}]`, value[i], fieldSchema.items, itemErrors); errors.push(...itemErrors); } } } /** * Performs tool-specific validation */ performToolSpecificValidation(toolName, args, errors) { // IMO validation for tools that use IMO numbers if (args.imo !== undefined && !toolName.startsWith('get_fleet_')) { const imoValidation = validateIMO(args.imo); if (!imoValidation.isValid) { errors.push({ field: 'imo', message: imoValidation.error || 'Invalid IMO number', value: args.imo, code: 'INVALID_IMO' }); } } // Coordinate validation for weather tools if (toolName === 'get_live_weather_by_coordinates') { this.validateCoordinates(args, errors); } // Date range validation for historical data tools if (args.start_date !== undefined && args.end_date !== undefined) { this.validateDateRange(args.start_date, args.end_date, errors); } // Search query validation if (toolName === 'smart_voyage_search') { this.validateSearchQuery(args, errors); } // Casefile validation if (toolName === 'write_casefile_data') { this.validateCasefileData(args, errors); } } /** * Validates coordinate ranges */ validateCoordinates(args, errors) { if (args.latitude !== undefined) { if (args.latitude < -90 || args.latitude > 90) { errors.push({ field: 'latitude', message: `Latitude ${args.latitude} is outside valid range [-90, 90]`, value: args.latitude, code: 'INVALID_LATITUDE' }); } } if (args.longitude !== undefined) { if (args.longitude < -180 || args.longitude > 180) { errors.push({ field: 'longitude', message: `Longitude ${args.longitude} is outside valid range [-180, 180]`, value: args.longitude, code: 'INVALID_LONGITUDE' }); } } } /** * Validates date range */ validateDateRange(startDate, endDate, errors) { const start = new Date(startDate); const end = new Date(endDate); if (start > end) { errors.push({ field: 'date_range', message: `Start date ${startDate} is after end date ${endDate}`, value: { start_date: startDate, end_date: endDate }, code: 'INVALID_DATE_RANGE' }); } // Check if dates are not too far in the future const now = new Date(); const maxFutureDate = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year from now if (start > maxFutureDate) { errors.push({ field: 'start_date', message: `Start date ${startDate} is too far in the future`, value: startDate, code: 'DATE_TOO_FAR_FUTURE' }); } if (end > maxFutureDate) { errors.push({ field: 'end_date', message: `End date ${endDate} is too far in the future`, value: endDate, code: 'DATE_TOO_FAR_FUTURE' }); } } /** * Validates search query parameters */ validateSearchQuery(args, errors) { // Validate max_results if (args.max_results !== undefined) { if (args.max_results < 1 || args.max_results > 100) { errors.push({ field: 'max_results', message: `max_results ${args.max_results} is outside valid range [1, 100]`, value: args.max_results, code: 'INVALID_MAX_RESULTS' }); } } // Validate query length if (args.query && args.query.length > 1000) { errors.push({ field: 'query', message: `Query length ${args.query.length} exceeds maximum of 1000 characters`, value: args.query.length, code: 'QUERY_TOO_LONG' }); } } /** * Validates casefile data */ validateCasefileData(args, errors) { // Validate importance score if (args.importance !== undefined) { if (args.importance < 0 || args.importance > 100) { errors.push({ field: 'importance', message: `Importance score ${args.importance} is outside valid range [0, 100]`, value: args.importance, code: 'INVALID_IMPORTANCE_SCORE' }); } } // Validate text field lengths const textFields = ['casefileName', 'casefileSummary', 'currentStatus', 'summary', 'topic', 'facts', 'detailed_report']; for (const field of textFields) { if (args[field] !== undefined) { if (args[field].length > 10000) { errors.push({ field, message: `${field} length ${args[field].length} exceeds maximum of 10000 characters`, value: args[field].length, code: 'TEXT_FIELD_TOO_LONG' }); } } } } } // Export singleton instance export const validationMiddleware = new ValidationMiddleware(); //# sourceMappingURL=validation-middleware.js.map