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
JavaScript
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