UNPKG

survey-mcp-server

Version:

Survey management server handling survey creation, response collection, analysis, and reporting with database access for data management

534 lines 25.6 kB
/** * Secure Tool Handler - Refactored to use service layer with proper validation */ import { middlewareChain } from '../middleware/index.js'; import { serviceManager } from '../services/index.js'; import { securityManager } from '../security/index.js'; import { logger } from '../utils/logger.js'; import { ValidationError, DatabaseError, SearchError } from '../middleware/error-handling.js'; export class SecureToolHandler { constructor() { this.executionCount = new Map(); this.databaseService = serviceManager.getDatabaseService(); this.searchService = serviceManager.getSearchService(); this.externalApiService = serviceManager.getExternalApiService(); } async handleCallTool(name, arguments_, options = {}) { const context = { toolName: name, sessionId: arguments_.session_id, userId: arguments_.user_id, timestamp: new Date(), correlationId: this.generateCorrelationId() }; const { enableAuditLogging = true, enableMetrics = true, maxExecutionTime = 60000, ...middlewareOptions } = options; try { // Audit logging if (enableAuditLogging) { this.logToolExecution(context, 'started', arguments_); } // Metrics tracking if (enableMetrics) { this.updateExecutionMetrics(name); } // Execute tool with middleware protection const result = await middlewareChain.processToolCall(name, arguments_, async (sanitizedInput) => this.executeSecureTool(name, sanitizedInput, context), { ...middlewareOptions, timeout: maxExecutionTime }); // Audit successful execution if (enableAuditLogging) { this.logToolExecution(context, 'completed', null, result); } return result; } catch (error) { // Audit failed execution if (enableAuditLogging) { this.logToolExecution(context, 'failed', null, null, error); } // Return standardized error response return middlewareChain.handleError(error, name); } } async executeSecureTool(name, sanitizedInput, context) { logger.debug(`Executing secure tool: ${name}`, { correlationId: context.correlationId, sanitizedInput: securityManager.sanitizeLogData(sanitizedInput) }); switch (name) { case "universal_certificate_survey_search": return await this.universalCertificateSurveySearch(sanitizedInput, context); case "list_extended_certificate_records": return await this.listExtendedCertificateRecords(sanitizedInput, context); case "list_records_expiring_within_days": return await this.listRecordsExpiringWithinDays(sanitizedInput, context); case "list_records_by_status": return await this.listRecordsByStatus(sanitizedInput, context); case "get_class_certificate_status": return await this.getClassCertificateStatus(sanitizedInput, context); case "get_class_survey_status": return await this.getClassSurveyStatus(sanitizedInput, context); case "get_coc_notes_memo_status": return await this.getCocNotesMemoStatus(sanitizedInput, context); case "get_vessel_dry_docking_status": return await this.getVesselDryDockingStatus(sanitizedInput, context); case "get_next_periodical_survey_details": return await this.getNextPeriodicalSurveyDetails(sanitizedInput, context); case "get_cms_items_status": return await this.getCmsItemsStatus(sanitizedInput, context); case "get_expired_certificates_from_shippalm": return await this.getExpiredCertificatesFromShippalm(sanitizedInput, context); case "write_casefile_data": return await this.writeCasefileData(sanitizedInput, context); case "retrieve_casefile_data": return await this.retrieveCasefileData(sanitizedInput, context); case "get_fleet_annual_survey_status": return await this.getFleetAnnualSurveyStatus(sanitizedInput, context); case "get_fleet_dry_docking_status": return await this.getFleetDryDockingStatus(sanitizedInput, context); case "get_fleet_ihm_certificate_status": return await this.getFleetIhmCertificateStatus(sanitizedInput, context); case "get_fleet_lsa_ffa_certificate_status": return await this.getFleetLsaFfaCertificateStatus(sanitizedInput, context); // Classification society tools case "class_ccs_survey_status_download": return await this.classCcsSurveyStatusDownload(sanitizedInput, context); case "class_nk_survey_status_download": return await this.classNkSurveyStatusDownload(sanitizedInput, context); case "class_kr_survey_status_download": return await this.classKrSurveyStatusDownload(sanitizedInput, context); case "class_dnv_survey_status_download": return await this.classDnvSurveyStatusDownload(sanitizedInput, context); case "class_lr_survey_status_download": return await this.classLrSurveyStatusDownload(sanitizedInput, context); case "class_bv_survey_status_download": return await this.classBvSurveyStatusDownload(sanitizedInput, context); case "class_abs_survey_status_download": return await this.classAbsSurveyStatusDownload(sanitizedInput, context); default: throw new ValidationError(`Unknown tool: ${name}`); } } async universalCertificateSurveySearch(input, context) { try { const { query = "*", filters = {}, sort_by = "relevance", sort_order = "asc", max_results = 10 } = input; // Validate IMO if provided if (filters.imo) { const imoValidation = securityManager.validateAndSanitizeIMO(filters.imo); if (!imoValidation.isValid) { throw new ValidationError(`Invalid IMO: ${imoValidation.issues.join(', ')}`); } filters.imo = imoValidation.sanitizedIMO; } // Build search query const searchQuery = { q: query === "*" ? "*" : query, query_by: "certificateSurveyEquipmentName,certificateNumber,issuingAuthority", include_fields: "imo,vesselName,certificateSurveyEquipmentName,isExtended,issuingAuthority,currentStatus,dataSource,type,issueDate,expiryDate,windowStartDate,windowEndDate,daysToExpiry,certificateNumber,certificateLink", per_page: Math.min(Math.max(max_results, 1), 100), // Clamp between 1-100 filter_by: this.buildFilterString(filters), sort_by: sort_by !== "relevance" ? `${sort_by}:${sort_order}` : undefined }; // Remove undefined properties Object.keys(searchQuery).forEach(key => { if (searchQuery[key] === undefined) { delete searchQuery[key]; } }); // Execute search with circuit breaker and retry const searchResults = await this.searchService.search("certificate", searchQuery, { enableFallback: true, timeout: 10000 }); // Process and sanitize results const processedHits = searchResults.hits.map(hit => { const document = this.convertCertificateDates(hit.document); return { id: document.id || document._id, score: hit.text_match || 0, document: this.sanitizeDocument(document) }; }); const result = { found: searchResults.found, out_of: searchResults.out_of, page: searchResults.page, search_time_ms: searchResults.search_time_ms, hits: processedHits, metadata: { query_processed: query, filters_applied: filters, correlation_id: context.correlationId } }; // Store search analytics await this.storeSearchAnalytics(context, { query, filters, results_count: searchResults.found, execution_time: searchResults.search_time_ms }); return [{ type: "text", text: JSON.stringify(result, null, 2), title: `Certificate search results for '${query}'`, format: "json" }]; } catch (error) { if (error instanceof SearchError && this.searchService.isServiceAvailable()) { // Provide fallback response when search is degraded but available return [{ type: "text", text: JSON.stringify({ error: "Search service degraded", message: "Please try again later", correlation_id: context.correlationId }), title: "Search Unavailable" }]; } throw error; } } async listExtendedCertificateRecords(input, context) { const { imo, recordType = [], per_page = 250 } = input; // Validate IMO const imoValidation = securityManager.validateAndSanitizeIMO(imo); if (!imoValidation.isValid) { throw new ValidationError(`Invalid IMO: ${imoValidation.issues.join(', ')}`); } // Build database query const query = { filter: { imo: parseInt(imoValidation.sanitizedIMO), isExtended: true, ...(recordType.length > 0 && { type: { $in: recordType } }) }, limit: Math.min(Math.max(per_page, 1), 500), sort: { expiryDate: 1 } }; try { const result = await this.databaseService.findDocuments("certificates", query); const processedData = result.data.map(doc => this.sanitizeDocument(doc)); return [{ type: "text", text: JSON.stringify({ total: result.total, limit: result.limit, has_more: result.hasMore, records: processedData, metadata: { imo: imoValidation.sanitizedIMO, record_types: recordType, correlation_id: context.correlationId } }, null, 2), title: `Extended certificate records for IMO ${imoValidation.sanitizedIMO}`, format: "json" }]; } catch (error) { if (error instanceof DatabaseError) { throw new DatabaseError(`Failed to retrieve extended certificate records for IMO ${imoValidation.sanitizedIMO}`, { originalError: error.message, imo: imoValidation.sanitizedIMO }); } throw error; } } async listRecordsExpiringWithinDays(input, context) { const { imo, recordType = [], daysToExpiry, per_page = 250 } = input; // Validate inputs const imoValidation = securityManager.validateAndSanitizeIMO(imo); if (!imoValidation.isValid) { throw new ValidationError(`Invalid IMO: ${imoValidation.issues.join(', ')}`); } if (typeof daysToExpiry !== 'number' || daysToExpiry < -365 || daysToExpiry > 365) { throw new ValidationError('Days to expiry must be a number between -365 and 365'); } // Build database query const query = { filter: { imo: parseInt(imoValidation.sanitizedIMO), daysToExpiry: { $lte: daysToExpiry }, ...(recordType.length > 0 && { type: { $in: recordType } }) }, limit: Math.min(Math.max(per_page, 1), 500), sort: { daysToExpiry: 1 } }; try { const result = await this.databaseService.findDocuments("certificates", query); const processedData = result.data.map(doc => this.sanitizeDocument(doc)); return [{ type: "text", text: JSON.stringify({ total: result.total, limit: result.limit, has_more: result.hasMore, records: processedData, metadata: { imo: imoValidation.sanitizedIMO, record_types: recordType, days_to_expiry_limit: daysToExpiry, correlation_id: context.correlationId } }, null, 2), title: `Records expiring within ${daysToExpiry} days for IMO ${imoValidation.sanitizedIMO}`, format: "json" }]; } catch (error) { if (error instanceof DatabaseError) { throw new DatabaseError(`Failed to retrieve expiring records for IMO ${imoValidation.sanitizedIMO}`, { originalError: error.message, imo: imoValidation.sanitizedIMO }); } throw error; } } async listRecordsByStatus(input, context) { const { imo, recordType = [], status = [], perPage = 250 } = input; // Validate inputs const imoValidation = securityManager.validateAndSanitizeIMO(imo); if (!imoValidation.isValid) { throw new ValidationError(`Invalid IMO: ${imoValidation.issues.join(', ')}`); } const validStatuses = ['IN_ORDER', 'IN_WINDOW', 'EXPIRED']; const invalidStatuses = status.filter((s) => !validStatuses.includes(s)); if (invalidStatuses.length > 0) { throw new ValidationError(`Invalid status values: ${invalidStatuses.join(', ')}`); } // Build database query const query = { filter: { imo: parseInt(imoValidation.sanitizedIMO), ...(recordType.length > 0 && { type: { $in: recordType } }), ...(status.length > 0 && { currentStatus: { $in: status } }) }, limit: Math.min(Math.max(perPage, 1), 500), sort: { expiryDate: 1 } }; try { const result = await this.databaseService.findDocuments("certificates", query); const processedData = result.data.map(doc => this.sanitizeDocument(doc)); return [{ type: "text", text: JSON.stringify({ total: result.total, limit: result.limit, has_more: result.hasMore, records: processedData, metadata: { imo: imoValidation.sanitizedIMO, record_types: recordType, status_filter: status, correlation_id: context.correlationId } }, null, 2), title: `Records by status for IMO ${imoValidation.sanitizedIMO}`, format: "json" }]; } catch (error) { if (error instanceof DatabaseError) { throw new DatabaseError(`Failed to retrieve records by status for IMO ${imoValidation.sanitizedIMO}`, { originalError: error.message, imo: imoValidation.sanitizedIMO }); } throw error; } } // Placeholder implementations for other tools - would be implemented similarly async getClassCertificateStatus(input, context) { return [{ type: "text", text: "Class certificate status - implementation pending", title: "Placeholder" }]; } async getClassSurveyStatus(input, context) { return [{ type: "text", text: "Class survey status - implementation pending", title: "Placeholder" }]; } async getCocNotesMemoStatus(input, context) { return [{ type: "text", text: "CoC notes memo status - implementation pending", title: "Placeholder" }]; } async getVesselDryDockingStatus(input, context) { return [{ type: "text", text: "Vessel dry docking status - implementation pending", title: "Placeholder" }]; } async getNextPeriodicalSurveyDetails(input, context) { return [{ type: "text", text: "Next periodical survey details - implementation pending", title: "Placeholder" }]; } async getCmsItemsStatus(input, context) { return [{ type: "text", text: "CMS items status - implementation pending", title: "Placeholder" }]; } async getExpiredCertificatesFromShippalm(input, context) { return [{ type: "text", text: "Expired certificates from Shippalm - implementation pending", title: "Placeholder" }]; } async writeCasefileData(input, context) { return [{ type: "text", text: "Write casefile data - implementation pending", title: "Placeholder" }]; } async retrieveCasefileData(input, context) { return [{ type: "text", text: "Retrieve casefile data - implementation pending", title: "Placeholder" }]; } async getFleetAnnualSurveyStatus(input, context) { return [{ type: "text", text: "Fleet annual survey status - implementation pending", title: "Placeholder" }]; } async getFleetDryDockingStatus(input, context) { return [{ type: "text", text: "Fleet dry docking status - implementation pending", title: "Placeholder" }]; } async getFleetIhmCertificateStatus(input, context) { return [{ type: "text", text: "Fleet IHM certificate status - implementation pending", title: "Placeholder" }]; } async getFleetLsaFfaCertificateStatus(input, context) { return [{ type: "text", text: "Fleet LSA/FFA certificate status - implementation pending", title: "Placeholder" }]; } // Classification society download tools - would use browser automation service async classCcsSurveyStatusDownload(input, context) { return [{ type: "text", text: "CCS survey status download - implementation pending", title: "Placeholder" }]; } async classNkSurveyStatusDownload(input, context) { return [{ type: "text", text: "NK survey status download - implementation pending", title: "Placeholder" }]; } async classKrSurveyStatusDownload(input, context) { return [{ type: "text", text: "KR survey status download - implementation pending", title: "Placeholder" }]; } async classDnvSurveyStatusDownload(input, context) { return [{ type: "text", text: "DNV survey status download - implementation pending", title: "Placeholder" }]; } async classLrSurveyStatusDownload(input, context) { return [{ type: "text", text: "LR survey status download - implementation pending", title: "Placeholder" }]; } async classBvSurveyStatusDownload(input, context) { return [{ type: "text", text: "BV survey status download - implementation pending", title: "Placeholder" }]; } async classAbsSurveyStatusDownload(input, context) { return [{ type: "text", text: "ABS survey status download - implementation pending", title: "Placeholder" }]; } // Helper methods buildFilterString(filters) { if (!filters || Object.keys(filters).length === 0) { return undefined; } const filterParts = []; for (const [key, value] of Object.entries(filters)) { if (value === null || value === undefined) { continue; } if (key.endsWith("_range")) { const fieldBase = key.replace("_range", ""); const rangeValue = value; let minVal = rangeValue?.min_days || rangeValue?.start_date; let maxVal = rangeValue?.max_days || rangeValue?.end_date; if (typeof minVal === 'string') { const date = new Date(minVal); minVal = Math.floor(date.getTime() / 1000); } if (typeof maxVal === 'string') { const date = new Date(maxVal); maxVal = Math.floor(date.getTime() / 1000); } if (minVal !== null && minVal !== undefined) { filterParts.push(`${fieldBase}:>=${minVal}`); } if (maxVal !== null && maxVal !== undefined) { filterParts.push(`${fieldBase}:<=${maxVal}`); } } else { const escapedValue = typeof value === 'string' ? JSON.stringify(value).slice(1, -1) : value; filterParts.push(`${key}:=${escapedValue}`); } } return filterParts.length > 0 ? filterParts.join(" && ") : undefined; } convertCertificateDates(data) { if (!data) return data; const dateFields = [ 'issueDate', 'extensionDate', 'expiryDate', 'windowStartDate', 'windowEndDate' ]; const convertTimestamp = (timestamp) => { try { if (typeof timestamp === 'string' && timestamp.includes('-') && timestamp.length > 10) { return timestamp; } let numericTimestamp = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp; if (typeof numericTimestamp === 'number' && !isNaN(numericTimestamp) && numericTimestamp > 0) { const date = numericTimestamp < 10000000000 ? new Date(numericTimestamp * 1000) : new Date(numericTimestamp); return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } } catch (error) { logger.warn(`Failed to convert timestamp: ${timestamp}`); } return timestamp; }; const result = {}; for (const [key, value] of Object.entries(data)) { if (dateFields.includes(key)) { result[key] = convertTimestamp(value); } else { result[key] = value; } } return result; } sanitizeDocument(document) { // Remove sensitive or internal fields const sanitized = { ...document }; delete sanitized.embedding; delete sanitized._internal; delete sanitized.password; delete sanitized.secret; return securityManager.sanitizeLogData(sanitized); } async storeSearchAnalytics(context, analytics) { try { const analyticsRecord = { tool_name: context.toolName, session_id: context.sessionId, user_id: context.userId, correlation_id: context.correlationId, timestamp: context.timestamp, analytics: securityManager.sanitizeLogData(analytics) }; await this.databaseService.insertDocument("tool_analytics", analyticsRecord); } catch (error) { // Don't fail the main operation if analytics fails logger.warn('Failed to store search analytics:', error); } } logToolExecution(context, status, input, output, error) { const logData = { tool_name: context.toolName, session_id: context.sessionId, user_id: context.userId, correlation_id: context.correlationId, status, timestamp: context.timestamp, ...(input && { input: securityManager.sanitizeLogData(input) }), ...(output && { output_size: JSON.stringify(output).length }), ...(error && { error: securityManager.sanitizeError(error) }) }; if (status === 'failed') { logger.error('Tool execution failed', logData); } else { logger.info('Tool execution', logData); } } updateExecutionMetrics(toolName) { const current = this.executionCount.get(toolName) || 0; this.executionCount.set(toolName, current + 1); } generateCorrelationId() { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } getExecutionMetrics() { return Object.fromEntries(this.executionCount); } resetMetrics() { this.executionCount.clear(); } } export const secureToolHandler = new SecureToolHandler(); //# sourceMappingURL=secure-handler.js.map