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