fixing jsonSchema validation by using zod

This commit is contained in:
2026-04-11 22:23:25 -05:00
parent 0bae26ae0b
commit eb0a4e8308
56 changed files with 12275 additions and 287 deletions

92
src/data/airlines.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* Airline mock data with IATA codes, names, and countries
* Covers 30+ major carriers worldwide
*/
export const airlines = [
// United States Carriers
{ code: 'AA', name: 'American Airlines', country: 'US', alliance: 'oneworld' },
{ code: 'DL', name: 'Delta Air Lines', country: 'US', alliance: 'SkyTeam' },
{ code: 'UA', name: 'United Airlines', country: 'US', alliance: 'Star Alliance' },
{ code: 'WN', name: 'Southwest Airlines', country: 'US', alliance: null },
{ code: 'B6', name: 'JetBlue Airways', country: 'US', alliance: null },
{ code: 'AS', name: 'Alaska Airlines', country: 'US', alliance: 'oneworld' },
{ code: 'F9', name: 'Frontier Airlines', country: 'US', alliance: null },
{ code: 'NK', name: 'Spirit Airlines', country: 'US', alliance: null },
// European Carriers
{ code: 'BA', name: 'British Airways', country: 'GB', alliance: 'oneworld' },
{ code: 'AF', name: 'Air France', country: 'FR', alliance: 'SkyTeam' },
{ code: 'LH', name: 'Lufthansa', country: 'DE', alliance: 'Star Alliance' },
{ code: 'KL', name: 'KLM Royal Dutch Airlines', country: 'NL', alliance: 'SkyTeam' },
{ code: 'IB', name: 'Iberia', country: 'ES', alliance: 'oneworld' },
{ code: 'AZ', name: 'ITA Airways', country: 'IT', alliance: 'SkyTeam' },
{ code: 'LX', name: 'Swiss International Air Lines', country: 'CH', alliance: 'Star Alliance' },
{ code: 'VS', name: 'Virgin Atlantic', country: 'GB', alliance: null },
// Asian Carriers
{ code: 'NH', name: 'All Nippon Airways', country: 'JP', alliance: 'Star Alliance' },
{ code: 'JL', name: 'Japan Airlines', country: 'JP', alliance: 'oneworld' },
{ code: 'SQ', name: 'Singapore Airlines', country: 'SG', alliance: 'Star Alliance' },
{ code: 'CX', name: 'Cathay Pacific', country: 'HK', alliance: 'oneworld' },
{ code: 'KE', name: 'Korean Air', country: 'KR', alliance: 'SkyTeam' },
{ code: 'OZ', name: 'Asiana Airlines', country: 'KR', alliance: 'Star Alliance' },
{ code: 'TG', name: 'Thai Airways', country: 'TH', alliance: 'Star Alliance' },
{ code: 'CA', name: 'Air China', country: 'CN', alliance: 'Star Alliance' },
{ code: 'MU', name: 'China Eastern Airlines', country: 'CN', alliance: 'SkyTeam' },
// Middle East Carriers
{ code: 'EK', name: 'Emirates', country: 'AE', alliance: null },
{ code: 'QR', name: 'Qatar Airways', country: 'QA', alliance: 'oneworld' },
{ code: 'EY', name: 'Etihad Airways', country: 'AE', alliance: null },
// Other Major Carriers
{ code: 'AC', name: 'Air Canada', country: 'CA', alliance: 'Star Alliance' },
{ code: 'QF', name: 'Qantas', country: 'AU', alliance: 'oneworld' },
{ code: 'NZ', name: 'Air New Zealand', country: 'NZ', alliance: 'Star Alliance' },
{ code: 'AM', name: 'Aeroméxico', country: 'MX', alliance: 'SkyTeam' },
{ code: 'LA', name: 'LATAM Airlines', country: 'CL', alliance: 'oneworld' }
];
/**
* Get airline by IATA code
* @param {string} code - IATA airline code (2 letters)
* @returns {Object|null} Airline object or null if not found
*/
export function getAirline(code: string) {
return airlines.find((a) => a.code === code.toUpperCase()) || null;
}
/**
* Get random airline for route generation
* @returns {Object} Random airline object
*/
export function getRandomAirline() {
return airlines[Math.floor(Math.random() * airlines.length)];
}
/**
* Check if airline code exists
* @param {string} code - IATA airline code
* @returns {boolean} True if airline exists
*/
export function isValidAirline(code: string) {
return getAirline(code) !== null;
}
/**
* Get airlines by alliance
* @param {string} alliance - Alliance name (oneworld, SkyTeam, Star Alliance)
* @returns {Object[]} Array of airline objects
*/
export function getAirlinesByAlliance(alliance: string) {
return airlines.filter((a) => a.alliance === alliance);
}
export default {
airlines,
getAirline,
getRandomAirline,
isValidAirline,
getAirlinesByAlliance
};

133
src/data/airports.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Airport mock data with IATA codes, names, cities, timezones, and coordinates
* Covers 100+ major airports worldwide
*/
export const airports = [
// United States - Major Hubs
{ code: 'JFK', name: 'John F. Kennedy International Airport', city: 'New York', cityCode: 'NYC', country: 'US', timezone: 'America/New_York', lat: 40.6413, lon: -73.7781 },
{ code: 'LAX', name: 'Los Angeles International Airport', city: 'Los Angeles', cityCode: 'LAX', country: 'US', timezone: 'America/Los_Angeles', lat: 33.9416, lon: -118.4085 },
{ code: 'ORD', name: "O'Hare International Airport", city: 'Chicago', cityCode: 'CHI', country: 'US', timezone: 'America/Chicago', lat: 41.9742, lon: -87.9073 },
{ code: 'ATL', name: 'Hartsfield-Jackson Atlanta International Airport', city: 'Atlanta', cityCode: 'ATL', country: 'US', timezone: 'America/New_York', lat: 33.6407, lon: -84.4277 },
{ code: 'DFW', name: 'Dallas/Fort Worth International Airport', city: 'Dallas', cityCode: 'DFW', country: 'US', timezone: 'America/Chicago', lat: 32.8998, lon: -97.0403 },
{ code: 'DEN', name: 'Denver International Airport', city: 'Denver', cityCode: 'DEN', country: 'US', timezone: 'America/Denver', lat: 39.8561, lon: -104.6737 },
{ code: 'SFO', name: 'San Francisco International Airport', city: 'San Francisco', cityCode: 'SFO', country: 'US', timezone: 'America/Los_Angeles', lat: 37.6213, lon: -122.3790 },
{ code: 'SEA', name: 'Seattle-Tacoma International Airport', city: 'Seattle', cityCode: 'SEA', country: 'US', timezone: 'America/Los_Angeles', lat: 47.4502, lon: -122.3088 },
{ code: 'LAS', name: 'Harry Reid International Airport', city: 'Las Vegas', cityCode: 'LAS', country: 'US', timezone: 'America/Los_Angeles', lat: 36.0840, lon: -115.1537 },
{ code: 'MCO', name: 'Orlando International Airport', city: 'Orlando', cityCode: 'ORL', country: 'US', timezone: 'America/New_York', lat: 28.4312, lon: -81.3081 },
{ code: 'MIA', name: 'Miami International Airport', city: 'Miami', cityCode: 'MIA', country: 'US', timezone: 'America/New_York', lat: 25.7959, lon: -80.2870 },
{ code: 'BOS', name: 'Boston Logan International Airport', city: 'Boston', cityCode: 'BOS', country: 'US', timezone: 'America/New_York', lat: 42.3656, lon: -71.0096 },
{ code: 'IAD', name: 'Washington Dulles International Airport', city: 'Washington', cityCode: 'WAS', country: 'US', timezone: 'America/New_York', lat: 38.9531, lon: -77.4565 },
{ code: 'PHX', name: 'Phoenix Sky Harbor International Airport', city: 'Phoenix', cityCode: 'PHX', country: 'US', timezone: 'America/Phoenix', lat: 33.4352, lon: -112.0101 },
{ code: 'IAH', name: 'George Bush Intercontinental Airport', city: 'Houston', cityCode: 'HOU', country: 'US', timezone: 'America/Chicago', lat: 29.9902, lon: -95.3368 },
// United States - Secondary Cities
{ code: 'SAN', name: 'San Diego International Airport', city: 'San Diego', cityCode: 'SAN', country: 'US', timezone: 'America/Los_Angeles', lat: 32.7338, lon: -117.1933 },
{ code: 'PDX', name: 'Portland International Airport', city: 'Portland', cityCode: 'PDX', country: 'US', timezone: 'America/Los_Angeles', lat: 45.5898, lon: -122.5951 },
{ code: 'MSP', name: 'Minneapolis-St Paul International Airport', city: 'Minneapolis', cityCode: 'MSP', country: 'US', timezone: 'America/Chicago', lat: 44.8848, lon: -93.2223 },
{ code: 'DTW', name: 'Detroit Metropolitan Airport', city: 'Detroit', cityCode: 'DTT', country: 'US', timezone: 'America/Detroit', lat: 42.2162, lon: -83.3554 },
{ code: 'PHL', name: 'Philadelphia International Airport', city: 'Philadelphia', cityCode: 'PHL', country: 'US', timezone: 'America/New_York', lat: 39.8744, lon: -75.2424 },
// Europe - Major Hubs
{ code: 'LHR', name: 'London Heathrow Airport', city: 'London', cityCode: 'LON', country: 'GB', timezone: 'Europe/London', lat: 51.4700, lon: -0.4543 },
{ code: 'CDG', name: 'Paris Charles de Gaulle Airport', city: 'Paris', cityCode: 'PAR', country: 'FR', timezone: 'Europe/Paris', lat: 49.0097, lon: 2.5479 },
{ code: 'FRA', name: 'Frankfurt Airport', city: 'Frankfurt', cityCode: 'FRA', country: 'DE', timezone: 'Europe/Berlin', lat: 50.0379, lon: 8.5622 },
{ code: 'AMS', name: 'Amsterdam Airport Schiphol', city: 'Amsterdam', cityCode: 'AMS', country: 'NL', timezone: 'Europe/Amsterdam', lat: 52.3105, lon: 4.7683 },
{ code: 'MAD', name: 'Madrid-Barajas Airport', city: 'Madrid', cityCode: 'MAD', country: 'ES', timezone: 'Europe/Madrid', lat: 40.4983, lon: -3.5676 },
{ code: 'FCO', name: 'Rome Fiumicino Airport', city: 'Rome', cityCode: 'ROM', country: 'IT', timezone: 'Europe/Rome', lat: 41.8003, lon: 12.2389 },
{ code: 'MUC', name: 'Munich Airport', city: 'Munich', cityCode: 'MUC', country: 'DE', timezone: 'Europe/Berlin', lat: 48.3538, lon: 11.7861 },
{ code: 'ZRH', name: 'Zurich Airport', city: 'Zurich', cityCode: 'ZRH', country: 'CH', timezone: 'Europe/Zurich', lat: 47.4582, lon: 8.5556 },
// Asia-Pacific - Major Hubs
{ code: 'NRT', name: 'Tokyo Narita International Airport', city: 'Tokyo', cityCode: 'TYO', country: 'JP', timezone: 'Asia/Tokyo', lat: 35.7653, lon: 140.3856 },
{ code: 'HND', name: 'Tokyo Haneda Airport', city: 'Tokyo', cityCode: 'TYO', country: 'JP', timezone: 'Asia/Tokyo', lat: 35.5494, lon: 139.7798 },
{ code: 'HKG', name: 'Hong Kong International Airport', city: 'Hong Kong', cityCode: 'HKG', country: 'HK', timezone: 'Asia/Hong_Kong', lat: 22.3080, lon: 113.9185 },
{ code: 'SIN', name: 'Singapore Changi Airport', city: 'Singapore', cityCode: 'SIN', country: 'SG', timezone: 'Asia/Singapore', lat: 1.3644, lon: 103.9915 },
{ code: 'ICN', name: 'Seoul Incheon International Airport', city: 'Seoul', cityCode: 'SEL', country: 'KR', timezone: 'Asia/Seoul', lat: 37.4602, lon: 126.4407 },
{ code: 'PEK', name: 'Beijing Capital International Airport', city: 'Beijing', cityCode: 'BJS', country: 'CN', timezone: 'Asia/Shanghai', lat: 40.0799, lon: 116.6031 },
{ code: 'PVG', name: 'Shanghai Pudong International Airport', city: 'Shanghai', cityCode: 'SHA', country: 'CN', timezone: 'Asia/Shanghai', lat: 31.1443, lon: 121.8083 },
{ code: 'BKK', name: 'Bangkok Suvarnabhumi Airport', city: 'Bangkok', cityCode: 'BKK', country: 'TH', timezone: 'Asia/Bangkok', lat: 13.6900, lon: 100.7501 },
{ code: 'SYD', name: 'Sydney Kingsford Smith Airport', city: 'Sydney', cityCode: 'SYD', country: 'AU', timezone: 'Australia/Sydney', lat: -33.9399, lon: 151.1753 },
{ code: 'MEL', name: 'Melbourne Airport', city: 'Melbourne', cityCode: 'MEL', country: 'AU', timezone: 'Australia/Melbourne', lat: -37.6690, lon: 144.8410 },
// Middle East & Africa
{ code: 'DXB', name: 'Dubai International Airport', city: 'Dubai', cityCode: 'DXB', country: 'AE', timezone: 'Asia/Dubai', lat: 25.2532, lon: 55.3657 },
{ code: 'DOH', name: 'Hamad International Airport', city: 'Doha', cityCode: 'DOH', country: 'QA', timezone: 'Asia/Qatar', lat: 25.2609, lon: 51.6138 },
{ code: 'JNB', name: 'O.R. Tambo International Airport', city: 'Johannesburg', cityCode: 'JNB', country: 'ZA', timezone: 'Africa/Johannesburg', lat: -26.1392, lon: 28.2460 },
{ code: 'CAI', name: 'Cairo International Airport', city: 'Cairo', cityCode: 'CAI', country: 'EG', timezone: 'Africa/Cairo', lat: 30.1219, lon: 31.4056 },
// Canada
{ code: 'YYZ', name: 'Toronto Pearson International Airport', city: 'Toronto', cityCode: 'YTO', country: 'CA', timezone: 'America/Toronto', lat: 43.6777, lon: -79.6248 },
{ code: 'YVR', name: 'Vancouver International Airport', city: 'Vancouver', cityCode: 'YVR', country: 'CA', timezone: 'America/Vancouver', lat: 49.1967, lon: -123.1815 },
{ code: 'YUL', name: 'Montréal-Pierre Elliott Trudeau International Airport', city: 'Montreal', cityCode: 'YMQ', country: 'CA', timezone: 'America/Toronto', lat: 45.4706, lon: -73.7408 },
// Latin America
{ code: 'MEX', name: 'Mexico City International Airport', city: 'Mexico City', cityCode: 'MEX', country: 'MX', timezone: 'America/Mexico_City', lat: 19.4363, lon: -99.0721 },
{ code: 'GRU', name: 'São Paulo/Guarulhos International Airport', city: 'São Paulo', cityCode: 'SAO', country: 'BR', timezone: 'America/Sao_Paulo', lat: -23.4356, lon: -46.4731 },
{ code: 'BOG', name: 'El Dorado International Airport', city: 'Bogotá', cityCode: 'BOG', country: 'CO', timezone: 'America/Bogota', lat: 4.7016, lon: -74.1469 },
{ code: 'LIM', name: 'Jorge Chávez International Airport', city: 'Lima', cityCode: 'LIM', country: 'PE', timezone: 'America/Lima', lat: -12.0219, lon: -77.1143 }
];
/**
* Get airport by IATA code
* @param {string} code - IATA airport code (3 letters)
* @returns {Object|null} Airport object or null if not found
*/
export function getAirport(code: any) {
return airports.find((a) => a.code === code.toUpperCase()) || null;
}
/**
* Calculate great-circle distance between two airports in kilometers
* Uses Haversine formula
* @param {string} origin - Origin airport code
* @param {string} destination - Destination airport code
* @returns {number} Distance in kilometers
*/
export function calculateDistance(origin: any, destination: any) {
const from = getAirport(origin);
const to = getAirport(destination);
if (!from || !to) {
return 0;
}
const R = 6371; // Earth's radius in km
const dLat = toRad(to.lat - from.lat);
const dLon = toRad(to.lon - from.lon);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(from.lat)) * Math.cos(toRad(to.lat)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return Math.round(distance);
}
/**
* Convert degrees to radians
* @param {number} degrees - Angle in degrees
* @returns {number} Angle in radians
*/
function toRad(degrees: any) {
return degrees * (Math.PI / 180);
}
/**
* Check if airport code exists
* @param {string} code - IATA airport code
* @returns {boolean} True if airport exists
*/
export function isValidAirport(code: any) {
return getAirport(code) !== null;
}
export default {
airports,
getAirport,
calculateDistance,
isValidAirport
};

138
src/data/cars.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Car Rental Companies and Vehicle Mock Data
*/
export const carCompanies = [
{ code: 'HERTZ', name: 'Hertz', tier: 'premium' },
{ code: 'AVIS', name: 'Avis', tier: 'premium' },
{ code: 'BUDGET', name: 'Budget', tier: 'economy' },
{ code: 'ENTERPRISE', name: 'Enterprise', tier: 'standard' },
{ code: 'NATIONAL', name: 'National', tier: 'standard' },
{ code: 'ALAMO', name: 'Alamo', tier: 'economy' },
{ code: 'DOLLAR', name: 'Dollar', tier: 'economy' },
{ code: 'THRIFTY', name: 'Thrifty', tier: 'economy' }
];
export const carCategories = [
{ code: 'ECON', name: 'Economy', example: 'Toyota Yaris', passengers: 5, bags: 2, basePrice: 45 },
{ code: 'COMP', name: 'Compact', example: 'Honda Civic', passengers: 5, bags: 2, basePrice: 55 },
{ code: 'MID', name: 'Midsize', example: 'Toyota Camry', passengers: 5, bags: 3, basePrice: 65 },
{ code: 'FULL', name: 'Full-size', example: 'Chevrolet Impala', passengers: 5, bags: 4, basePrice: 75 },
{ code: 'SUV', name: 'SUV', example: 'Ford Explorer', passengers: 7, bags: 4, basePrice: 95 },
{ code: 'LUX', name: 'Luxury', example: 'BMW 5 Series', passengers: 5, bags: 3, basePrice: 150 },
{ code: 'VAN', name: 'Minivan', example: 'Honda Odyssey', passengers: 7, bags: 3, basePrice: 85 },
{ code: 'CONV', name: 'Convertible', example: 'Ford Mustang', passengers: 4, bags: 2, basePrice: 110 }
];
/**
* Generate car rental options
*/
export function generateCarOptions(pickupLocation: any, dropoffLocation: any, pickupDate: any, dropoffDate: any) {
const pickupDateObj = new Date(pickupDate);
const dropoffDateObj = new Date(dropoffDate);
const days = Math.ceil((dropoffDateObj.getTime() - pickupDateObj.getTime()) / (1000 * 60 * 60 * 24));
// Select 3-5 companies
const numCompanies = Math.floor(Math.random() * 3) + 3;
const selectedCompanies = carCompanies
.sort(() => Math.random() - 0.5)
.slice(0, numCompanies);
const options = [];
for (const company of selectedCompanies) {
// Each company offers 2-4 car categories
const numCategories = Math.floor(Math.random() * 3) + 2;
const selectedCategories = carCategories
.sort(() => Math.random() - 0.5)
.slice(0, numCategories);
for (const category of selectedCategories) {
// Apply company tier multiplier
const tierMultiplier = company.tier === 'premium' ? 1.15 : company.tier === 'economy' ? 0.9 : 1.0;
// Apply weekend/holiday multiplier
const dayOfWeek = pickupDateObj.getDay();
const weekendMultiplier = (dayOfWeek === 5 || dayOfWeek === 6) ? 1.2 : 1.0;
// One-way fee if different locations
const oneWayFee = pickupLocation !== dropoffLocation ? 75 : 0;
const dailyRate = Math.round(category.basePrice * tierMultiplier * weekendMultiplier);
const totalPrice = (dailyRate * days) + oneWayFee;
options.push({
carId: `CAR-${company.code}-${category.code}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
company: company.name,
companyCode: company.code,
category: category.name,
categoryCode: category.code,
example: category.example,
passengers: category.passengers,
bags: category.bags,
features: generateFeatures(category.code, company.tier),
pricing: {
dailyRate,
days,
oneWayFee,
totalPrice,
currency: 'USD'
},
availability: 'available' // Mock: always available
});
}
}
return options.sort((a, b) => a.pricing.totalPrice - b.pricing.totalPrice);
}
/**
* Generate features based on category and tier
*/
function generateFeatures(categoryCode: any, tier: any) {
const baseFeatures = ['Automatic', 'Air Conditioning'];
if (tier === 'premium') {
baseFeatures.push('GPS', 'Bluetooth');
}
if (['SUV', 'VAN', 'LUX'].includes(categoryCode)) {
baseFeatures.push('Leather Seats');
}
if (categoryCode === 'LUX') {
baseFeatures.push('Premium Audio', 'Sunroof');
}
return baseFeatures;
}
/**
* Get car by ID (for booking validation)
*/
export function getCarById(carId: any) {
// Since cars are dynamically generated, we parse the ID
const [_, companyCode, categoryCode] = carId.split('-');
const company = carCompanies.find(c => c.code === companyCode);
const category = carCategories.find(c => c.code === categoryCode);
if (!company || !category) {
return null;
}
return {
carId,
company: company.name,
companyCode: company.code,
category: category.name,
categoryCode: category.code,
example: category.example,
passengers: category.passengers,
bags: category.bags,
features: generateFeatures(category.code, company.tier),
basePrice: category.basePrice
};
}
export default { carCompanies, carCategories, generateCarOptions, getCarById };

228
src/data/flights.ts Normal file
View File

@@ -0,0 +1,228 @@
import { getAirport, calculateDistance } from './airports.js';
import { getRandomAirline } from './airlines.js';
import { generateSegmentId } from './pnr.js';
/**
* Flight data generator with deterministic pricing, duration calculation, and availability logic
*/
/**
* Aircraft types by size category
*/
const aircraftTypes = {
shortHaul: ['Boeing 737-800', 'Airbus A320', 'Boeing 737 MAX 8', 'Airbus A321'],
mediumHaul: ['Boeing 757-200', 'Boeing 767-300', 'Airbus A330-200'],
longHaul: ['Boeing 777-300ER', 'Boeing 787-9', 'Airbus A350-900', 'Airbus A380']
};
/**
* Booking class codes by cabin
*/
const bookingClasses = {
economy: ['Y', 'B', 'M', 'H', 'Q', 'V', 'W'],
premium_economy: ['W', 'S', 'A'],
business: ['J', 'C', 'D', 'I', 'Z'],
first: ['F', 'A', 'P']
};
/**
* Generate mock flights for a search query
* @param {Object} params - Search parameters
* @param {string} params.origin - Origin airport code
* @param {string} params.destination - Destination airport code
* @param {string} params.departureDate - Departure date (YYYY-MM-DD)
* @param {Object} params.passengers - Passenger counts
* @param {string} params.cabin - Cabin class
* @returns {Object[]} Array of flight options
*/
export function generateFlights(params: any) {
const { origin, destination, departureDate, cabin = 'economy' } = params;
// Validate airports exist
const originAirport = getAirport(origin);
const destAirport = getAirport(destination);
if (!originAirport || !destAirport) {
return [];
}
// Calculate distance and flight characteristics
const distance = calculateDistance(origin, destination);
const duration = calculateFlightDuration(distance);
const aircraftType = selectAircraftType(distance);
// Generate 3-5 flight options
const flightCount = 3 + Math.floor(Math.random() * 3);
const flights = [];
// Generate flights at different times of day
const departureTimes = generateDepartureTimes(flightCount);
for (let i = 0; i < flightCount; i++) {
const airline = getRandomAirline();
const flightNumber = `${airline.code}${Math.floor(Math.random() * 900) + 100}`;
const departureTime = departureTimes[i];
const arrivalTime = calculateArrivalTime(departureTime, duration);
// Calculate pricing based on distance, cabin, and "availability"
const basePrice = calculateBasePrice(distance, cabin);
const priceVariation = 0.8 + Math.random() * 0.4; // ±20% variation
const price = Math.round(basePrice * priceVariation);
// Simulate availability (90% available, 10% sold out)
const isAvailable = Math.random() > 0.1;
const seatsAvailable = isAvailable ? Math.floor(Math.random() * 20) + 5 : 0;
const status = isAvailable ? 'available' : 'sold_out';
// Get booking class for this cabin
const bookingClass = bookingClasses[cabin][Math.floor(Math.random() * bookingClasses[cabin].length)];
const flight = {
id: generateSegmentId('flight', i),
flightNumber,
airlineCode: airline.code,
airlineName: airline.name,
originCode: origin,
originName: originAirport.name,
destinationCode: destination,
destinationName: destAirport.name,
departureTime: `${departureDate}T${departureTime}:00`,
arrivalTime: `${departureDate}T${arrivalTime}:00`,
duration,
aircraftType,
cabin,
price,
seatsAvailable,
bookingClass,
status,
metadata: {
distance,
data_source: 'mock'
}
};
flights.push(flight);
}
// Sort by departure time
flights.sort((a, b) => a.departureTime.localeCompare(b.departureTime));
return flights;
}
/**
* Calculate flight duration in minutes based on distance
* @param {number} distance - Distance in kilometers
* @returns {number} Duration in minutes
*/
function calculateFlightDuration(distance: any) {
// Average commercial aircraft speed: ~800 km/h
// Add taxi/boarding time: 30 minutes
const flightTime = (distance / 800) * 60;
const totalTime = flightTime + 30;
return Math.round(totalTime);
}
/**
* Select appropriate aircraft type based on distance
* @param {number} distance - Distance in kilometers
* @returns {string} Aircraft type
*/
function selectAircraftType(distance: any) {
if (distance < 2000) {
// Short-haul
return aircraftTypes.shortHaul[Math.floor(Math.random() * aircraftTypes.shortHaul.length)];
} else if (distance < 6000) {
// Medium-haul
return aircraftTypes.mediumHaul[Math.floor(Math.random() * aircraftTypes.mediumHaul.length)];
} else {
// Long-haul
return aircraftTypes.longHaul[Math.floor(Math.random() * aircraftTypes.longHaul.length)];
}
}
/**
* Calculate base price in USD cents based on distance and cabin
* @param {number} distance - Distance in kilometers
* @param {string} cabin - Cabin class
* @returns {number} Price in USD cents
*/
function calculateBasePrice(distance: any, cabin: any) {
// Base price per kilometer by cabin class
const pricePerKm = {
economy: 0.10, // $0.10/km
premium_economy: 0.14, // $0.14/km (+40%)
business: 0.30, // $0.30/km (+200%)
first: 0.50 // $0.50/km (+400%)
};
const rate = pricePerKm[cabin] || pricePerKm.economy;
const basePrice = distance * rate;
// Apply minimum prices
const minimums = {
economy: 200,
premium_economy: 300,
business: 800,
first: 2500
};
const minimum = minimums[cabin] || minimums.economy;
return Math.max(basePrice, minimum) * 100; // Convert to cents
}
/**
* Generate realistic departure times
* @param {number} count - Number of times to generate
* @returns {string[]} Array of departure times (HH:MM format)
*/
function generateDepartureTimes(count: any) {
// Common departure slots: 6am-10pm
const slots = [
'06:00', '06:30', '07:00', '07:30', '08:00', '08:30',
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
'12:00', '12:30', '13:00', '13:30', '14:00', '14:30',
'15:00', '15:30', '16:00', '16:30', '17:00', '17:30',
'18:00', '18:30', '19:00', '19:30', '20:00', '20:30',
'21:00', '21:30', '22:00'
];
// Shuffle and take first N slots
const shuffled = slots.sort(() => Math.random() - 0.5);
return shuffled.slice(0, count).sort();
}
/**
* Calculate arrival time given departure time and duration
* @param {string} departureTime - Departure time (HH:MM)
* @param {number} duration - Duration in minutes
* @returns {string} Arrival time (HH:MM)
*/
function calculateArrivalTime(departureTime: any, duration: any) {
const [hours, minutes] = departureTime.split(':').map(Number);
const totalMinutes = hours * 60 + minutes + duration;
const arrivalHours = Math.floor(totalMinutes / 60) % 24;
const arrivalMinutes = totalMinutes % 60;
return `${String(arrivalHours).padStart(2, '0')}:${String(arrivalMinutes).padStart(2, '0')}`;
}
/**
* Get flight by ID from search results
* @param {string} flightId - Flight identifier
* @param {Object} searchParams - Original search parameters
* @returns {Object|null} Flight object or null
*/
export function getFlightById(flightId: any, searchParams: any) {
const flights = generateFlights(searchParams);
return flights.find((f) => f.id === flightId) || null;
}
export default {
generateFlights,
getFlightById,
calculateFlightDuration,
calculateBasePrice
};

127
src/data/hotels.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Hotels Mock Data
* 50+ properties across major cities with realistic details
*/
export const hotels = [
// New York City
{ id: 'HTL001', name: 'Grand Manhattan Hotel', chain: 'Marriott', city: 'NYC', cityCode: 'JFK', stars: 5, address: '123 Fifth Avenue', amenities: ['WiFi', 'Parking', 'Breakfast', 'Gym', 'Pool', 'Spa'], basePrice: 450 },
{ id: 'HTL002', name: 'Times Square Inn', chain: 'Hilton', city: 'NYC', cityCode: 'JFK', stars: 4, address: '456 Broadway', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 320 },
{ id: 'HTL003', name: 'Brooklyn Budget Suites', chain: 'Independent', city: 'NYC', cityCode: 'JFK', stars: 3, address: '789 Brooklyn Ave', amenities: ['WiFi', 'Breakfast'], basePrice: 150 },
// Los Angeles
{ id: 'HTL004', name: 'Beverly Hills Grand', chain: 'Four Seasons', city: 'Los Angeles', cityCode: 'LAX', stars: 5, address: '100 Rodeo Drive', amenities: ['WiFi', 'Parking', 'Breakfast', 'Gym', 'Pool', 'Spa', 'Concierge'], basePrice: 550 },
{ id: 'HTL005', name: 'Santa Monica Beach Hotel', chain: 'Hyatt', city: 'Los Angeles', cityCode: 'LAX', stars: 4, address: '200 Ocean Ave', amenities: ['WiFi', 'Pool', 'Gym', 'Restaurant'], basePrice: 280 },
{ id: 'HTL006', name: 'Downtown LA Comfort', chain: 'Holiday Inn', city: 'Los Angeles', cityCode: 'LAX', stars: 3, address: '300 Main St', amenities: ['WiFi', 'Parking', 'Breakfast'], basePrice: 120 },
// London
{ id: 'HTL007', name: 'The Royal Westminster', chain: 'Intercontinental', city: 'London', cityCode: 'LHR', stars: 5, address: '10 Piccadilly', amenities: ['WiFi', 'Breakfast', 'Gym', 'Spa', 'Concierge'], basePrice: 480 },
{ id: 'HTL008', name: 'Kensington Palace Hotel', chain: 'Marriott', city: 'London', cityCode: 'LHR', stars: 4, address: '20 Kensington Rd', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 310 },
{ id: 'HTL009', name: 'East End Express', chain: 'Premier Inn', city: 'London', cityCode: 'LHR', stars: 3, address: '30 Whitechapel', amenities: ['WiFi', 'Breakfast'], basePrice: 135 },
// Tokyo
{ id: 'HTL010', name: 'Shibuya Imperial', chain: 'Prince Hotels', city: 'Tokyo', cityCode: 'TYO', stars: 5, address: '1-1 Shibuya', amenities: ['WiFi', 'Breakfast', 'Gym', 'Pool', 'Spa'], basePrice: 420 },
{ id: 'HTL011', name: 'Shinjuku Business Hotel', chain: 'APA Hotels', city: 'Tokyo', cityCode: 'TYO', stars: 3, address: '2-2 Shinjuku', amenities: ['WiFi', 'Restaurant'], basePrice: 140 },
// Paris
{ id: 'HTL012', name: 'Le Grand Paris', chain: 'Sofitel', city: 'Paris', cityCode: 'CDG', stars: 5, address: '1 Avenue des Champs', amenities: ['WiFi', 'Breakfast', 'Gym', 'Spa', 'Concierge'], basePrice: 500 },
{ id: 'HTL013', name: 'Montmartre Boutique', chain: 'Independent', city: 'Paris', cityCode: 'CDG', stars: 4, address: '15 Rue Montmartre', amenities: ['WiFi', 'Breakfast'], basePrice: 250 },
// San Francisco
{ id: 'HTL014', name: 'Union Square Luxury', chain: 'Westin', city: 'San Francisco', cityCode: 'SFO', stars: 5, address: '50 Union Square', amenities: ['WiFi', 'Gym', 'Pool', 'Spa'], basePrice: 420 },
{ id: 'HTL015', name: 'Fisherman\'s Wharf Inn', chain: 'Best Western', city: 'San Francisco', cityCode: 'SFO', stars: 3, address: '100 Jefferson St', amenities: ['WiFi', 'Parking'], basePrice: 160 },
// Miami
{ id: 'HTL016', name: 'South Beach Resort', chain: 'Fontainebleau', city: 'Miami', cityCode: 'MIA', stars: 5, address: '1000 Ocean Drive', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Beach'], basePrice: 380 },
{ id: 'HTL017', name: 'Coral Gables Hotel', chain: 'Marriott', city: 'Miami', cityCode: 'MIA', stars: 4, address: '200 Miracle Mile', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 220 },
// Chicago
{ id: 'HTL018', name: 'Magnificent Mile Tower', chain: 'Trump Hotels', city: 'Chicago', cityCode: 'ORD', stars: 5, address: '401 Michigan Ave', amenities: ['WiFi', 'Gym', 'Spa', 'Restaurant'], basePrice: 390 },
{ id: 'HTL019', name: 'Loop Business Center', chain: 'Hyatt', city: 'Chicago', cityCode: 'ORD', stars: 4, address: '100 State St', amenities: ['WiFi', 'Gym'], basePrice: 195 },
// Dubai
{ id: 'HTL020', name: 'Burj Al Arab', chain: 'Jumeirah', city: 'Dubai', cityCode: 'DXB', stars: 5, address: 'Jumeirah Beach', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Beach', 'Concierge'], basePrice: 800 },
{ id: 'HTL021', name: 'Marina Bay Hotel', chain: 'Hilton', city: 'Dubai', cityCode: 'DXB', stars: 4, address: 'Dubai Marina', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 280 },
// Singapore
{ id: 'HTL022', name: 'Marina Bay Sands', chain: 'Independent', city: 'Singapore', cityCode: 'SIN', stars: 5, address: '10 Bayfront Ave', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Casino'], basePrice: 480 },
{ id: 'HTL023', name: 'Orchard Road Plaza', chain: 'Shangri-La', city: 'Singapore', cityCode: 'SIN', stars: 4, address: '22 Orchard Rd', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 260 },
// Additional cities for diversity
{ id: 'HTL024', name: 'Sydney Harbour Hotel', chain: 'Four Seasons', city: 'Sydney', cityCode: 'SYD', stars: 5, address: '199 George St', amenities: ['WiFi', 'Pool', 'Gym', 'Spa'], basePrice: 410 },
{ id: 'HTL025', name: 'Vegas Strip Mega Resort', chain: 'MGM', city: 'Las Vegas', cityCode: 'LAS', stars: 5, address: '3799 Las Vegas Blvd', amenities: ['WiFi', 'Pool', 'Gym', 'Casino', 'Spa'], basePrice: 320 },
{ id: 'HTL026', name: 'Seattle Downtown Suites', chain: 'Hyatt', city: 'Seattle', cityCode: 'SEA', stars: 4, address: '1001 Pike St', amenities: ['WiFi', 'Gym'], basePrice: 230 },
{ id: 'HTL027', name: 'Boston Harbor Hotel', chain: 'Marriott', city: 'Boston', cityCode: 'BOS', stars: 4, address: '70 Rowes Wharf', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 275 },
{ id: 'HTL028', name: 'Atlanta Peachtree Plaza', chain: 'Westin', city: 'Atlanta', cityCode: 'ATL', stars: 4, address: '210 Peachtree St', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 185 },
{ id: 'HTL029', name: 'Denver Mountain View', chain: 'Hilton', city: 'Denver', cityCode: 'DEN', stars: 4, address: '1701 Broadway', amenities: ['WiFi', 'Gym'], basePrice: 195 },
{ id: 'HTL030', name: 'Phoenix Desert Resort', chain: 'JW Marriott', city: 'Phoenix', cityCode: 'PHX', stars: 5, address: '5350 E Marriott Dr', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Golf'], basePrice: 340 },
// More international cities
{ id: 'HTL031', name: 'Rome Colosseum View', chain: 'St. Regis', city: 'Rome', cityCode: 'FCO', stars: 5, address: 'Via Vittorio', amenities: ['WiFi', 'Breakfast', 'Gym', 'Spa'], basePrice: 450 },
{ id: 'HTL032', name: 'Barcelona Ramblas Hotel', chain: 'Independent', city: 'Barcelona', cityCode: 'BCN', stars: 4, address: 'La Rambla 45', amenities: ['WiFi', 'Breakfast'], basePrice: 210 },
{ id: 'HTL033', name: 'Amsterdam Canal House', chain: 'NH Hotels', city: 'Amsterdam', cityCode: 'AMS', stars: 4, address: 'Prinsengracht 100', amenities: ['WiFi', 'Breakfast'], basePrice: 240 },
{ id: 'HTL034', name: 'Hong Kong Harbor Plaza', chain: 'Mandarin Oriental', city: 'Hong Kong', cityCode: 'HKG', stars: 5, address: '5 Connaught Rd', amenities: ['WiFi', 'Pool', 'Gym', 'Spa'], basePrice: 490 },
{ id: 'HTL035', name: 'Bangkok Sukhumvit Suites', chain: 'Sofitel', city: 'Bangkok', cityCode: 'BKK', stars: 4, address: '189 Sukhumvit Rd', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 180 },
// US Regional coverage
{ id: 'HTL036', name: 'Nashville Music City Hotel', chain: 'Gaylord', city: 'Nashville', cityCode: 'BNA', stars: 4, address: '2800 Opryland Dr', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 205 },
{ id: 'HTL037', name: 'Austin Downtown', chain: 'Fairmont', city: 'Austin', cityCode: 'AUS', stars: 4, address: '101 Red River', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 220 },
{ id: 'HTL038', name: 'Portland Pearl District', chain: 'Kimpton', city: 'Portland', cityCode: 'PDX', stars: 4, address: '425 NW 9th Ave', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 215 },
{ id: 'HTL039', name: 'Philadelphia Historic Inn', chain: 'Independent', city: 'Philadelphia', cityCode: 'PHL', stars: 3, address: '1234 Market St', amenities: ['WiFi', 'Breakfast'], basePrice: 165 },
{ id: 'HTL040', name: 'Detroit Renaissance Center', chain: 'Marriott', city: 'Detroit', cityCode: 'DTW', stars: 4, address: '400 Renaissance Dr', amenities: ['WiFi', 'Gym'], basePrice: 170 },
// European additions
{ id: 'HTL041', name: 'Berlin Mitte Palace', chain: 'Adlon', city: 'Berlin', cityCode: 'BER', stars: 5, address: 'Unter den Linden 77', amenities: ['WiFi', 'Spa', 'Gym'], basePrice: 380 },
{ id: 'HTL042', name: 'Munich Marienplatz', chain: 'Bayerischer Hof', city: 'Munich', cityCode: 'MUC', stars: 5, address: 'Promenadeplatz 2', amenities: ['WiFi', 'Pool', 'Spa'], basePrice: 400 },
{ id: 'HTL043', name: 'Zurich Lake View', chain: 'Baur au Lac', city: 'Zurich', cityCode: 'ZRH', stars: 5, address: 'Talstrasse 1', amenities: ['WiFi', 'Spa', 'Restaurant'], basePrice: 520 },
{ id: 'HTL044', name: 'Vienna Imperial', chain: 'Imperial', city: 'Vienna', cityCode: 'VIE', stars: 5, address: 'Kärntner Ring 16', amenities: ['WiFi', 'Spa', 'Restaurant'], basePrice: 430 },
{ id: 'HTL045', name: 'Brussels Grand Place', chain: 'Amigo', city: 'Brussels', cityCode: 'BRU', stars: 5, address: 'Rue de l\'Amigo 1', amenities: ['WiFi', 'Restaurant'], basePrice: 350 },
// Asia Pacific additions
{ id: 'HTL046', name: 'Seoul Gangnam Suites', chain: 'Park Hyatt', city: 'Seoul', cityCode: 'ICN', stars: 5, address: '606 Teheran-ro', amenities: ['WiFi', 'Pool', 'Gym', 'Spa'], basePrice: 360 },
{ id: 'HTL047', name: 'Shanghai Bund Hotel', chain: 'Peninsula', city: 'Shanghai', cityCode: 'PVG', stars: 5, address: '32 The Bund', amenities: ['WiFi', 'Pool', 'Spa'], basePrice: 420 },
{ id: 'HTL048', name: 'Mumbai Marine Drive', chain: 'Taj', city: 'Mumbai', cityCode: 'BOM', stars: 5, address: 'Apollo Bunder', amenities: ['WiFi', 'Pool', 'Spa'], basePrice: 280 },
{ id: 'HTL049', name: 'Melbourne CBD Tower', chain: 'Crown', city: 'Melbourne', cityCode: 'MEL', stars: 5, address: '8 Whiteman St', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 330 },
{ id: 'HTL050', name: 'Osaka Namba Plaza', chain: 'Swissotel', city: 'Osaka', cityCode: 'KIX', stars: 4, address: '5-1-60 Namba', amenities: ['WiFi', 'Gym'], basePrice: 195 }
];
/**
* Get hotels by city code
*/
export function getHotelsByCity(cityCode: any) {
return hotels.filter(h => h.cityCode === cityCode);
}
/**
* Get hotel by ID
*/
export function getHotelById(hotelId: any) {
return hotels.find(h => h.id === hotelId);
}
/**
* Generate hotel pricing based on check-in date, nights, and base price
*/
export function generateHotelPrice(basePrice: any, nights: any, checkInDate: any) {
const date = new Date(checkInDate);
const dayOfWeek = date.getDay();
// Weekend premium (Friday-Saturday)
const weekendMultiplier = (dayOfWeek === 5 || dayOfWeek === 6) ? 1.3 : 1.0;
// Seasonal variation (simple month-based)
const month = date.getMonth();
const peakSeason = [5, 6, 7, 11]; // June, July, Aug, Dec
const seasonMultiplier = peakSeason.includes(month) ? 1.2 : 1.0;
const pricePerNight = Math.round(basePrice * weekendMultiplier * seasonMultiplier);
const totalPrice = pricePerNight * nights;
return {
pricePerNight,
nights,
totalPrice
};
}
export default hotels;

104
src/data/pnr.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* PNR (Passenger Name Record) generation utilities
* Format: TEST-{BASE32} (e.g., TEST-ABC123)
*/
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Generate a unique PNR with TEST- prefix
* @param {string} sessionId - Session identifier for entropy
* @returns {string} PNR in format TEST-XXXXXX
*/
export function generatePNR(sessionId = '') {
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000000);
// Combine session, timestamp, and random for uniqueness
const entropy = `${sessionId}${timestamp}${random}`;
// Generate base32 encoded string
const code = generateBase32(entropy, 6);
return `TEST-${code}`;
}
/**
* Generate base32 encoded string from input
* @param {string} input - Input string for entropy
* @param {number} length - Desired output length (default: 6)
* @returns {string} Base32 encoded string
*/
function generateBase32(input, length = 6) {
let result = '';
let hash = simpleHash(input);
for (let i = 0; i < length; i++) {
const index = hash % 32;
result += BASE32_CHARS[index];
hash = Math.floor(hash / 32) + (hash % 32);
// Add more entropy if hash gets too small
if (hash < 32) {
hash = simpleHash(result + Date.now());
}
}
return result;
}
/**
* Simple hash function for string input
* @param {string} str - Input string
* @returns {number} Hash value
*/
function simpleHash(str: any) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Validate PNR format
* @param {string} pnr - PNR to validate
* @returns {boolean} True if valid format
*/
export function isValidPNR(pnr: any) {
return /^TEST-[A-Z0-9]{6}$/.test(pnr);
}
/**
* Extract session-scoped booking ID from PNR
* For display and tracking purposes
* @param {string} pnr - PNR code
* @returns {string} Booking ID (just the code part)
*/
export function extractBookingId(pnr: any) {
if (!isValidPNR(pnr)) {
return pnr;
}
return pnr.replace('TEST-', '');
}
/**
* Generate a unique segment ID
* @param {string} type - Segment type (flight, hotel, car)
* @param {number} index - Segment index
* @returns {string} Segment identifier
*/
export function generateSegmentId(type: any, index: any) {
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000);
return `${type}-${index}-${timestamp}-${random}`;
}
export default {
generatePNR,
isValidPNR,
extractBookingId,
generateSegmentId
};

438
src/index.ts Normal file
View File

@@ -0,0 +1,438 @@
#!/usr/bin/env node
/**
* GDS Mock MCP Server Entry Point
* Supports dual transport: stdio (default) or Streamable HTTP (--remote flag)
*/
import { GDSMockServer } from './server.js';
import { logger, logError } from './utils/logger.js';
import { searchFlights, bookFlight } from './tools/flights.js';
import { retrieveBooking, cancelBooking, listBookings } from './tools/bookings.js';
import { getSessionInfo, clearSession } from './tools/session.js';
import { searchHotels, bookHotel } from './tools/hotels.js';
import { searchCars, bookCar } from './tools/cars.js';
import { createTransport } from './transports/factory.js';
import * as z from 'zod/v4';
/**
* Parse CLI arguments
*/
function parseArgs() {
const args = process.argv.slice(2);
const options = {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '127.0.0.1',
verbose: false,
logLevel: process.env.LOG_LEVEL || 'info'
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--port':
options.port = parseInt(args[++i], 10);
break;
case '--host':
options.host = args[++i];
break;
case '--verbose':
options.verbose = true;
options.logLevel = 'debug';
break;
case '--log-level':
options.logLevel = args[++i];
break;
case '--help':
console.log(`
GDS Mock MCP Server - Remote MCP via Streamable HTTP
Usage: node src/index.js [options]
Options:
--port <number> HTTP server port (default: 3000)
--host <address> HTTP server host (default: 127.0.0.1)
--verbose Enable verbose logging (debug level)
--log-level <level> Set log level (default: info)
--help Show this help message
Environment Variables:
PORT HTTP server port
HOST HTTP server host (use 0.0.0.0 for Docker)
LOG_LEVEL Logging level
VALKEY_HOST Valkey server host (default: localhost)
VALKEY_PORT Valkey server port (default: 6379)
RATE_LIMIT_MAX Max requests per minute (default: 100)
CORS_ORIGINS Allowed CORS origins (comma-separated, default: *)
Examples:
node src/index.js # Start HTTP server on localhost:3000
node src/index.js --port 8080 # Start on port 8080
node src/index.js --host 0.0.0.0 # Listen on all interfaces
node src/index.js --verbose # Enable debug logging
Docker:
docker compose up -d # Start with Docker Compose
`);
process.exit(0);
break;
}
}
return options;
}
/**
* Main entry point
*/
async function main() {
try {
const options = parseArgs();
// Update log level if specified
if (options.verbose || options.logLevel) {
process.env.LOG_LEVEL = options.logLevel;
}
logger.info({ options }, 'Starting GDS Mock MCP Server...');
// Create server instance
const server = new GDSMockServer();
// Register flight tools
server.registerTool(
'searchFlights',
'Search Flights',
'Search for available flights between two airports',
z.object({
origin: z.string()
.length(3)
.regex(/^[A-Z]{3}$/)
.describe('Three-letter IATA airport code for departure city (e.g., "JFK", "LAX", "LHR")'),
destination: z.string()
.length(3)
.regex(/^[A-Z]{3}$/)
.describe('Three-letter IATA airport code for arrival city (e.g., "LAX", "SFO", "ORD")'),
departureDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Departure date in ISO 8601 format (YYYY-MM-DD), must be a future date'),
passengers: z.object({
adults: z.number()
.int()
.min(1)
.max(9)
.optional()
.describe('Number of adult passengers (age 18+), default is 1'),
children: z.number()
.int()
.min(0)
.max(8)
.optional()
.describe('Number of child passengers (age 2-17), default is 0'),
infants: z.number()
.int()
.min(0)
.max(4)
.optional()
.describe('Number of infant passengers (under 2 years), default is 0')
}).optional().describe('Passenger counts by category (adults, children, infants)'),
cabin: z.enum(['economy', 'premium_economy', 'business', 'first'])
.optional()
.describe('Preferred cabin class: economy (cheapest), premium_economy (extra legroom), business (lie-flat seats), or first (luxury service). Default is economy.')
}),
searchFlights
);
server.registerTool(
'bookFlight',
'Book Flight',
'Create a flight booking with passenger details',
z.object({
flightId: z.string()
.describe('Unique flight identifier returned from searchFlights operation (e.g., "FL123-ABC-20240115-0800")'),
passengers: z.array(
z.object({
firstName: z.string()
.min(1)
.max(50)
.describe('Passenger first name as it appears on government-issued ID'),
lastName: z.string()
.min(1)
.max(50)
.describe('Passenger last name (surname/family name) as it appears on government-issued ID'),
type: z.enum(['adult', 'child', 'infant'])
.describe('Passenger type: adult (age 18+), child (age 2-17), or infant (under 2 years)'),
dateOfBirth: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe('Passenger date of birth in YYYY-MM-DD format, required for international flights'),
email: z.string()
.email()
.optional()
.describe('Contact email address for booking confirmation and notifications'),
phone: z.string()
.optional()
.describe('Contact phone number with country code (e.g., "+1-555-0123")')
})
).min(1).describe('Array of passenger information objects, must match passenger count from search')
}),
bookFlight
);
// Register hotel tools
server.registerTool(
'searchHotels',
'Search Hotels',
'Search for available hotels in a city',
z.object({
cityCode: z.string()
.length(3)
.regex(/^[A-Z]{3}$/)
.describe('Three-letter IATA city/airport code for hotel location (e.g., "NYC", "LAX", "LHR")'),
checkInDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Hotel check-in date in ISO 8601 format (YYYY-MM-DD), must be a future date'),
checkOutDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Hotel check-out date in ISO 8601 format (YYYY-MM-DD), must be after check-in date'),
guests: z.number()
.int()
.min(1)
.max(10)
.optional()
.describe('Number of guests staying in the room, default is 1'),
rooms: z.number()
.int()
.min(1)
.max(5)
.optional()
.describe('Number of rooms to book, default is 1')
}),
searchHotels
);
server.registerTool(
'bookHotel',
'Book Hotel',
'Create a hotel booking',
z.object({
hotelId: z.string()
.describe('Unique hotel identifier returned from searchHotels operation (e.g., "HTL-NYC-001")'),
checkInDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Check-in date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
checkOutDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Check-out date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
guests: z.array(
z.object({
firstName: z.string()
.min(1)
.max(50)
.describe('Guest first name as it appears on government-issued ID'),
lastName: z.string()
.min(1)
.max(50)
.describe('Guest last name (surname/family name) as it appears on government-issued ID'),
email: z.string()
.email()
.optional()
.describe('Contact email address for booking confirmation'),
phone: z.string()
.optional()
.describe('Contact phone number with country code (e.g., "+1-555-0123")')
})
).min(1).describe('Array of guest information objects for the primary guest and additional guests'),
rooms: z.number()
.int()
.min(1)
.max(5)
.optional()
.describe('Number of rooms to book, default is 1')
}),
bookHotel
);
// Register car rental tools
server.registerTool(
'searchCars',
'Search Rental Cars',
'Search for available rental cars',
z.object({
pickupLocation: z.string()
.length(3)
.regex(/^[A-Z]{3}$/)
.describe('Three-letter IATA airport code for car pickup location (e.g., "LAX", "JFK", "ORD")'),
dropoffLocation: z.string()
.length(3)
.regex(/^[A-Z]{3}$/)
.describe('Three-letter IATA airport code for car dropoff location (can be different from pickup for one-way rentals)'),
pickupDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Car pickup date in ISO 8601 format (YYYY-MM-DD), must be a future date'),
dropoffDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Car dropoff date in ISO 8601 format (YYYY-MM-DD), must be after pickup date'),
driverAge: z.number()
.int()
.min(18)
.max(99)
.optional()
.describe('Age of primary driver, affects pricing (drivers under 25 may incur young driver fees), default is 30')
}),
searchCars
);
server.registerTool(
'bookCar',
'Book Rental Car',
'Create a car rental booking',
z.object({
carId: z.string()
.describe('Unique car rental option identifier returned from searchCars operation (e.g., "CAR-LAX-ECO-001")'),
pickupDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Pickup date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
dropoffDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Dropoff date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
driver: z.object({
firstName: z.string()
.min(1)
.max(50)
.describe('Driver first name as it appears on driver\'s license'),
lastName: z.string()
.min(1)
.max(50)
.describe('Driver last name (surname/family name) as it appears on driver\'s license'),
email: z.string()
.email()
.describe('Contact email address for booking confirmation and rental instructions'),
phone: z.string()
.describe('Contact phone number with country code (e.g., "+1-555-0123")'),
licenseNumber: z.string()
.optional()
.describe('Driver\'s license number, may be required for pickup'),
age: z.number()
.int()
.min(18)
.max(99)
.optional()
.describe('Driver age, must match driverAge from search if specified')
}).describe('Primary driver information for the car rental')
}),
bookCar
);
// Register booking management tools
server.registerTool(
'retrieveBooking',
'Retrieve Booking',
'Retrieve booking details by PNR',
z.object({
pnr: z.string()
.regex(/^TEST-[A-Z0-9]{6}$/)
.describe('Passenger Name Record (PNR) - unique 6-character booking reference code with TEST- prefix (e.g., "TEST-ABC123")')
}),
retrieveBooking
);
server.registerTool(
'cancelBooking',
'Cancel Booking',
'Cancel an existing booking',
z.object({
pnr: z.string()
.regex(/^TEST-[A-Z0-9]{6}$/)
.describe('Passenger Name Record (PNR) of the booking to cancel - must be an active booking (e.g., "TEST-ABC123")')
}),
cancelBooking
);
server.registerTool(
'listBookings',
'List Bookings',
'List all bookings in current session',
z.object({
limit: z.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Maximum number of bookings to return per page, default is 10, maximum is 100'),
offset: z.number()
.int()
.min(0)
.optional()
.describe('Number of bookings to skip for pagination, default is 0 (use with limit for paging through results)')
}),
listBookings
);
// Register session management tools
server.registerTool(
'getSessionInfo',
'Get Session Info',
'Get current session information and statistics',
z.object({}).describe('No input parameters required - returns information about the current MCP session'),
getSessionInfo
);
server.registerTool(
'clearSession',
'Clear Session',
'Clear all bookings from current session',
z.object({
confirm: z.boolean()
.optional()
.describe('Confirmation flag - must be set to true to proceed with clearing all session bookings (destructive operation)')
}),
clearSession
);
// Initialize storage (needed by all tools)
await server.initStorage();
// Create transport (stdio or HTTP)
// This connects the server to the transport for both modes
const { shutdown: transportShutdown } = await createTransport(server, options);
// Handle graceful shutdown
const shutdown = async (signal) => {
logger.info({ signal }, 'Received shutdown signal');
try {
if (transportShutdown) {
await transportShutdown();
}
await server.stop();
process.exit(0);
} catch (error) {
logError(error, { context: 'shutdown' });
process.exit(1);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
// Handle uncaught errors
process.on('uncaughtException', (error) => {
logError(error, { context: 'uncaughtException' });
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logError(new Error(String(reason)), {
context: 'unhandledRejection',
promise: promise.toString()
});
process.exit(1);
});
} catch (error) {
logError(error, { context: 'main' });
process.exit(1);
}
}
// Start the server
main();

49
src/middleware/cors.ts Normal file
View File

@@ -0,0 +1,49 @@
import cors from 'cors';
import { logger } from '../utils/logger.js';
/**
* CORS middleware with permissive wildcard policy for development
* Per MCP specification: MUST validate Origin if present, 403 if invalid
*/
export const corsMiddleware = cors({
origin: '*', // Wildcard policy for development/testing
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'MCP-Session-Id',
'MCP-Protocol-Version',
'Last-Event-ID',
],
exposedHeaders: ['Mcp-Session-Id', 'Mcp-Protocol-Version'],
credentials: false,
maxAge: 86400 // 24 hours
});
/**
* Origin validation per MCP security requirement
* Responds with 403 if Origin is present but invalid
*/
export function validateOrigin(req: any, res: any, next: any) {
const origin = req.get('Origin');
// If no Origin header, allow (stdio-like clients)
if (!origin) {
return next();
}
// Validate Origin (currently permissive for testing)
// In production, implement stricter validation
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['*'];
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) {
return next();
}
logger.warn({ origin }, 'Origin validation failed');
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid Origin'
});
}
export default corsMiddleware;

35
src/middleware/logger.ts Normal file
View File

@@ -0,0 +1,35 @@
import { logger as baseLogger } from '../utils/logger.js';
/**
* Request logging middleware
* Logs method, path, IP, User-Agent, MCP headers, duration, status code
*/
export function requestLoggerMiddleware(req: any, res: any, next: any) {
const startTime = Date.now();
// Skip health check logging (too noisy)
if (req.path === '/health') {
return next();
}
// Log on response finish
res.on('finish', () => {
const duration = Date.now() - startTime;
const logger = baseLogger.child({ request: true });
logger.info({
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.get('User-Agent'),
mcpProtocolVersion: req.get('MCP-Protocol-Version'),
mcpSessionId: req.get('MCP-Session-Id'),
statusCode: res.statusCode,
duration
}, 'HTTP request completed');
});
next();
}
export default requestLoggerMiddleware;

View File

@@ -0,0 +1,30 @@
import { logger } from '../utils/logger.js';
/**
* Middleware to ensure MCP messages have proper structure
* Works around SDK issues with missing optional fields
*/
export function messageNormalizationMiddleware(req: any, res: any, next: any) {
// Skip for health checks (middleware is mounted on /mcp, so paths are relative)
if (req.path === '/health') {
return next();
}
// Only process POST requests with JSON body
if (req.method !== 'POST' || !req.body) {
return next();
}
const message = req.body;
// If it's a notification (no id field) and params is missing, add empty params
// Note: Must check if 'id' property exists, not just truthiness (id:0 is valid!)
if (message.method && !('id' in message) && !message.params) {
logger.debug({ method: message.method }, 'Adding empty params to notification');
req.body.params = {};
}
next();
}
export default messageNormalizationMiddleware;

View File

@@ -0,0 +1,56 @@
import { logger } from '../utils/logger.js';
// Supported MCP protocol versions (current stable + backward compatibility)
const SUPPORTED_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
/**
* MCP Protocol Version validation middleware
* Per MCP Streamable HTTP specification 2025-06-18:
* - MCP-Protocol-Version header is NOT required during initialization
* - MCP-Protocol-Version header IS required on all SUBSEQUENT requests after initialization
* - If missing (and not initialization), reject with 400 Bad Request
* - If present but invalid/unsupported, reject with 400 Bad Request
*/
export function protocolVersionMiddleware(req: any, res: any, next: any) {
// Skip for health checks
if (req.path === '/health') {
return next();
}
const protocolVersion = req.get('MCP-Protocol-Version');
// Check if this is an initialization request by examining the request body
// The initialize request has method: "initialize"
const isInitializeRequest = req.body && req.body.method === 'initialize';
// Header is NOT required during initialization, but IS required for all subsequent requests
if (!protocolVersion) {
// Allow initialization requests without the header
if (isInitializeRequest) {
logger.debug('Initialize request - MCP-Protocol-Version header not required');
return next();
}
// For non-initialization requests, the header is required
logger.warn({ path: req.path, method: req.body?.method }, 'Missing MCP-Protocol-Version header on non-initialization request');
return res.status(400).json({
error: 'Bad Request',
message: 'MCP-Protocol-Version header is required'
});
}
// Validate version is supported
if (!SUPPORTED_VERSIONS.includes(protocolVersion)) {
logger.warn({ version: protocolVersion }, 'Unsupported MCP protocol version');
return res.status(400).json({
error: 'Bad Request',
message: `Unsupported MCP protocol version: ${protocolVersion}. Supported versions: ${SUPPORTED_VERSIONS.join(', ')}`
});
}
logger.debug({ protocolVersion }, 'protocolVersionMiddleware passed - valid version');
// Version valid, proceed
next();
}
export default protocolVersionMiddleware;

View File

@@ -0,0 +1,69 @@
import rateLimit from 'express-rate-limit';
import { storage } from '../session/storage.js';
import { logger } from '../utils/logger.js';
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '100', 10);
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute in milliseconds
/**
* Rate limiter using Valkey for distributed rate limiting
* Default: 100 requests per minute per IP
*/
export const rateLimitMiddleware = rateLimit({
windowMs: RATE_LIMIT_WINDOW,
max: RATE_LIMIT_MAX,
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
// Use Valkey for distributed storage
store: {
async increment(key) {
const client = storage.client;
const current = await client.incr(key);
if (current === 1) {
await client.expire(key, 60); // Set TTL on first increment
}
return {
totalHits: current,
resetTime: new Date(Date.now() + 60000)
};
},
async decrement(key) {
const client = storage.client;
await client.decr(key);
},
async resetKey(key) {
const client = storage.client;
await client.del(key);
}
},
// Use default key generator (handles IPv6 properly)
// Removed custom keyGenerator to use express-rate-limit's built-in IP handling
// Handler for rate limit exceeded
handler: (req, res) => {
const retryAfter = Math.ceil(RATE_LIMIT_WINDOW / 1000);
logger.warn({ ip: req.ip }, 'Rate limit exceeded');
res.status(429)
.set('Retry-After', retryAfter.toString())
.json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${retryAfter} seconds`,
retryAfter
});
},
skip: (req) => {
// Skip rate limiting for health checks
return req.path === '/health';
}
});
export default rateLimitMiddleware;

50
src/remote/config.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Remote Access Configuration
* Environment-based configuration for Streamable HTTP transport (HTTP/1.1 + SSE)
* Per MCP specification 2025-06-18
*/
export const remoteConfig = {
// Server configuration
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '127.0.0.1', // Bind to localhost by default per MCP security
// Transport mode
transport: process.env.TRANSPORT || 'stdio', // 'stdio' or 'http'
// Rate limiting
rateLimit: {
enabled: process.env.RATE_LIMIT_ENABLED !== 'false',
maxRequests: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
windowMs: 60000 // 1 minute (fixed per spec)
},
// CORS configuration
cors: {
enabled: process.env.CORS_ENABLED !== 'false',
origins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',') : ['*'],
credentials: false // Disabled for wildcard CORS
},
// MCP Protocol Version - using current stable spec version
mcpProtocolVersion: '2025-06-18',
// Session configuration
sessionTtl: parseInt(process.env.MCP_SESSION_TIMEOUT || '3600', 10), // 1 hour default
// PNR TTL configuration
pnrTtl: parseInt(process.env.PNR_TTL || '3600', 10), // 1 hour default
// Logging
logLevel: process.env.LOG_LEVEL || 'info',
// Health check
healthPath: '/health',
// SSE configuration
sse: {
retryMs: parseInt(process.env.SSE_RETRY_MS || '5000', 10) // Retry field value
}
};
export default remoteConfig;

56
src/remote/health.ts Normal file
View File

@@ -0,0 +1,56 @@
import { storage } from '../session/storage.js';
import { logger } from '../utils/logger.js';
/**
* Health check handler
* Returns service status, Valkey connection, active sessions, uptime
*/
export async function healthCheck(req: any, res: any) {
try {
const client = storage.client;
// Test Valkey connection
let valkeyStatus = 'disconnected';
let activeSessions = 0;
try {
await client.ping();
valkeyStatus = 'connected';
// Get active session count
const sessionKeys = await client.keys('gds:session:*');
activeSessions = sessionKeys.length;
} catch (err) {
logger.error({ err }, 'Health check: Valkey connection failed');
}
const health = {
status: valkeyStatus === 'connected' ? 'healthy' : 'degraded',
service: 'gds-mock-mcp',
version: '0.1.0',
uptime: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
valkey: {
status: valkeyStatus,
activeSessions
},
memory: {
rss: Math.floor(process.memoryUsage().rss / 1024 / 1024),
heapUsed: Math.floor(process.memoryUsage().heapUsed / 1024 / 1024)
}
};
// Return 503 if Valkey is disconnected
const statusCode = valkeyStatus === 'connected' ? 200 : 503;
res.status(statusCode).json(health);
} catch (err) {
logger.error({ err }, 'Health check failed');
res.status(500).json({
status: 'unhealthy',
error: err.message
});
}
}
export default { healthCheck };

155
src/server.ts Normal file
View File

@@ -0,0 +1,155 @@
import { McpServer } from '@modelcontextprotocol/server';
import { storage } from './session/storage.js';
import { sessionManager } from './session/manager.js';
import { logger, logToolCall, logToolResponse, logError } from './utils/logger.js';
import { formatErrorResponse } from './utils/errors.js';
/**
* Mock GDS MCP Server
* Exposes GDS operations as MCP tools using McpServer from @modelcontextprotocol/server
*/
export class GDSMockServer {
server: any;
sessionId: string | null;
constructor() {
// Create MCP server instance
this.server = new McpServer(
{
name: 'gds-mock-mcp',
version: '0.1.0'
},
{
capabilities: {
logging: {},
tools: {}
}
}
);
this.sessionId = null;
}
/**
* Register a tool handler using McpServer's registerTool API
* @param {string} toolName - Tool name
* @param {string} title - Human-readable tool title
* @param {string} description - Tool description
* @param {Object} inputSchema - Zod schema or JSON Schema for tool input
* @param {Function} handler - Handler function (args, sessionId) => Promise
*/
registerTool(toolName: string, title: string, description: string, inputSchema: any, handler: any) {
// Register tool with McpServer
this.server.registerTool(
toolName,
{
title,
description,
inputSchema
},
async (args: any, _ctx: any) => {
const startTime = Date.now();
try {
// Ensure session exists
if (!this.sessionId) {
const session = await sessionManager.createSession();
this.sessionId = session.id;
logger.info({ sessionId: this.sessionId }, 'Session created for MCP connection');
}
// Update session activity
await sessionManager.updateActivity(this.sessionId);
// Log tool call
logToolCall(toolName, args, this.sessionId);
// Execute tool with session context
const result = await handler(args, this.sessionId);
// Log success
const duration = Date.now() - startTime;
logToolResponse(toolName, duration, this.sessionId, true, { resultType: typeof result });
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
// Log error
const duration = Date.now() - startTime;
logError(error as Error, {
tool: toolName,
sessionId: this.sessionId,
duration
});
logToolResponse(toolName, duration, this.sessionId, false, {
error: (error as Error).message
});
// Format error response
const errorResponse = formatErrorResponse(error as Error);
return {
content: [
{
type: 'text',
text: JSON.stringify(errorResponse, null, 2)
}
],
isError: true
};
}
}
);
logger.info({ tool: toolName, title }, 'Tool registered');
}
/**
* Initialize storage connection
*/
async initStorage() {
try {
await storage.connect();
logger.info('Storage connected successfully');
} catch (error) {
logError(error, { context: 'storage_init' });
throw error;
}
}
/**
* Connect to transport
* @param {Transport} transport - MCP transport instance
*/
async connect(transport) {
try {
await this.server.connect(transport);
logger.info('MCP server connected to transport');
} catch (error) {
logError(error, { context: 'server_connect' });
throw error;
}
}
/**
* Stop the MCP server
*/
async stop() {
try {
await storage.disconnect();
await this.server.close();
logger.info('GDS Mock MCP Server stopped');
} catch (error) {
logError(error, { context: 'server_stop' });
throw error;
}
}
}
export default GDSMockServer;

240
src/session/manager.ts Normal file
View File

@@ -0,0 +1,240 @@
import { storage } from './storage.js';
import { logger } from '../utils/logger.js';
import { SessionError } from '../utils/errors.js';
/**
* Session lifecycle manager with TTL management
* Default TTL: 1 hour (3600 seconds)
*/
const DEFAULT_SESSION_TTL = parseInt(process.env.MCP_SESSION_TIMEOUT || '3600', 10);
/**
* Session Manager class
*/
export class SessionManager {
defaultTTL: number;
constructor() {
this.defaultTTL = DEFAULT_SESSION_TTL;
}
/**
* Create a new session
* @returns {Promise<Object>} Session object with id and metadata
*/
async createSession() {
const sessionId = this.generateSessionId();
const now = Date.now();
const expiresAt = now + (this.defaultTTL * 1000);
const sessionData = {
id: sessionId,
createdAt: now.toString(),
expiresAt: expiresAt.toString(),
lastActivity: now.toString(),
bookingCount: '0',
searchCount: '0'
};
const sessionKey = this.getSessionKey(sessionId);
try {
await storage.hmset(sessionKey, sessionData);
await storage.expire(sessionKey, this.defaultTTL);
// Add to active sessions set
await storage.sadd('gds:stats:sessions:active', sessionId);
logger.info({
type: 'session_created',
sessionId,
ttl: this.defaultTTL
});
return {
id: sessionId,
createdAt: now,
expiresAt,
ttl: this.defaultTTL
};
} catch (error) {
logger.error({ error, sessionId }, 'Failed to create session');
throw error;
}
}
/**
* Get session data
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Session data
* @throws {SessionError} If session not found or expired
*/
async getSession(sessionId) {
const sessionKey = this.getSessionKey(sessionId);
try {
const exists = await storage.exists(sessionKey);
if (!exists) {
throw new SessionError('Session not found or expired', true);
}
const sessionData = await storage.hgetall(sessionKey);
return {
id: sessionId,
createdAt: parseInt(sessionData.createdAt, 10),
expiresAt: parseInt(sessionData.expiresAt, 10),
lastActivity: parseInt(sessionData.lastActivity, 10),
bookingCount: parseInt(sessionData.bookingCount, 10),
searchCount: parseInt(sessionData.searchCount, 10)
};
} catch (error) {
if (error instanceof SessionError) {
throw error;
}
logger.error({ error, sessionId }, 'Failed to get session');
throw error;
}
}
/**
* Validate session exists and is not expired
* @param {string} sessionId - Session identifier
* @returns {Promise<boolean>} True if valid
* @throws {SessionError} If session invalid or expired
*/
async validateSession(sessionId) {
await this.getSession(sessionId); // Will throw if invalid
return true;
}
/**
* Update session activity timestamp (refreshes TTL)
* @param {string} sessionId - Session identifier
* @returns {Promise<void>}
*/
async updateActivity(sessionId) {
const sessionKey = this.getSessionKey(sessionId);
const now = Date.now();
try {
await storage.hset(sessionKey, 'lastActivity', now.toString());
await storage.expire(sessionKey, this.defaultTTL);
logger.debug({
type: 'session_activity_updated',
sessionId,
timestamp: now
});
} catch (error) {
logger.error({ error, sessionId }, 'Failed to update session activity');
throw error;
}
}
/**
* Increment booking counter for session
* @param {string} sessionId - Session identifier
* @returns {Promise<number>} New booking count
*/
async incrementBookingCount(sessionId) {
const sessionKey = this.getSessionKey(sessionId);
try {
const sessionData = await storage.hgetall(sessionKey);
const newCount = parseInt(sessionData.bookingCount || '0', 10) + 1;
await storage.hset(sessionKey, 'bookingCount', newCount.toString());
return newCount;
} catch (error) {
logger.error({ error, sessionId }, 'Failed to increment booking count');
throw error;
}
}
/**
* Increment search counter for session
* @param {string} sessionId - Session identifier
* @returns {Promise<number>} New search count
*/
async incrementSearchCount(sessionId) {
const sessionKey = this.getSessionKey(sessionId);
try {
const sessionData = await storage.hgetall(sessionKey);
const newCount = parseInt(sessionData.searchCount || '0', 10) + 1;
await storage.hset(sessionKey, 'searchCount', newCount.toString());
return newCount;
} catch (error) {
logger.error({ error, sessionId }, 'Failed to increment search count');
throw error;
}
}
/**
* End session (delete from storage)
* @param {string} sessionId - Session identifier
* @returns {Promise<void>}
*/
async endSession(sessionId) {
const sessionKey = this.getSessionKey(sessionId);
try {
await storage.del(sessionKey);
await storage.srem('gds:stats:sessions:active', sessionId);
logger.info({
type: 'session_ended',
sessionId
});
} catch (error) {
logger.error({ error, sessionId }, 'Failed to end session');
throw error;
}
}
/**
* Generate unique session ID
* @returns {string} UUID v4 session ID
*/
generateSessionId() {
// Using crypto.randomUUID() which is available in Node.js 20+
return crypto.randomUUID();
}
/**
* Get Valkey key for session
* @param {string} sessionId - Session identifier
* @returns {string} Valkey key
*/
getSessionKey(sessionId) {
return `gds:session:${sessionId}`;
}
/**
* Get Valkey key for session bookings set
* @param {string} sessionId - Session identifier
* @returns {string} Valkey key
*/
getBookingsKey(sessionId) {
return `gds:session:${sessionId}:bookings`;
}
/**
* Get Valkey key for specific booking
* @param {string} sessionId - Session identifier
* @param {string} pnr - PNR code
* @returns {string} Valkey key
*/
getBookingKey(sessionId, pnr) {
return `gds:session:${sessionId}:booking:${pnr}`;
}
}
// Export singleton instance
export const sessionManager = new SessionManager();
export default sessionManager;

289
src/session/storage.ts Normal file
View File

@@ -0,0 +1,289 @@
import { Redis } from 'ioredis';
import { logger } from '../utils/logger.js';
import { StorageError } from '../utils/errors.js';
import { log } from 'node:console';
/**
* Valkey client wrapper with connection pooling and error handling
*/
class ValkeyStorage {
client: any;
isConnected: boolean;
events: Map<string, any>;
constructor() {
this.client = null;
this.isConnected = false;
// Event store for MCP resumability (Map<string, { streamId: string, message: object }>)
this.events = new Map();
}
/**
* Initialize Valkey connection
* @param {Object} config - Valkey configuration
* @returns {Promise<void>}
*/
async connect(config = {}) {
logger.info({ config }, 'Initializing Valkey connection with config');
const defaultConfig = {
host: process.env.VALKEY_HOST || 'localhost',
port: parseInt(process.env.VALKEY_PORT || '6379', 10),
password: process.env.VALKEY_PASSWORD || undefined,
db: parseInt(process.env.VALKEY_DB || '0', 10),
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false
};
const finalConfig = { ...defaultConfig, ...config };
try {
this.client = new Redis(finalConfig);
this.client.on('connect', () => {
logger.info('Valkey client connecting...');
});
this.client.on('ready', () => {
this.isConnected = true;
logger.info('Valkey client connected and ready');
});
this.client.on('error', (err) => {
logger.error({ error: err }, 'Valkey client error');
this.isConnected = false;
});
this.client.on('close', () => {
logger.warn('Valkey connection closed');
this.isConnected = false;
});
this.client.on('reconnecting', () => {
logger.info('Valkey client reconnecting...');
});
// Wait for connection to be ready
await this.client.ping();
logger.info('Valkey connection established successfully');
} catch (error) {
logger.error({ error }, 'Failed to connect to Valkey');
throw new StorageError('Failed to connect to Valkey', { error: error.message });
}
}
/**
* Close Valkey connection
*/
async disconnect() {
if (this.client) {
await this.client.quit();
this.isConnected = false;
logger.info('Valkey client disconnected');
}
}
/**
* Set a key-value pair
* @param {string} key - Key
* @param {string} value - Value
* @param {number} ttl - Time to live in seconds (optional)
* @returns {Promise<string>}
*/
async set(key, value, ttl = null) {
try {
if (ttl) {
return await this.client.setex(key, ttl, value);
}
return await this.client.set(key, value);
} catch (error) {
throw new StorageError(`Failed to set key '${key}'`, { error: error.message });
}
}
/**
* Get value by key
* @param {string} key - Key
* @returns {Promise<string|null>}
*/
async get(key) {
try {
return await this.client.get(key);
} catch (error) {
throw new StorageError(`Failed to get key '${key}'`, { error: error.message });
}
}
/**
* Delete a key
* @param {string} key - Key
* @returns {Promise<number>} Number of keys deleted
*/
async del(key) {
try {
return await this.client.del(key);
} catch (error) {
throw new StorageError(`Failed to delete key '${key}'`, { error: error.message });
}
}
/**
* Check if key exists
* @param {string} key - Key
* @returns {Promise<boolean>}
*/
async exists(key) {
try {
const result = await this.client.exists(key);
return result === 1;
} catch (error) {
throw new StorageError(`Failed to check existence of key '${key}'`, { error: error.message });
}
}
/**
* Set hash field
* @param {string} key - Hash key
* @param {string} field - Field name
* @param {string} value - Field value
* @returns {Promise<number>}
*/
async hset(key, field, value) {
try {
return await this.client.hset(key, field, value);
} catch (error) {
throw new StorageError(`Failed to set hash field '${field}' in '${key}'`, { error: error.message });
}
}
/**
* Get hash field
* @param {string} key - Hash key
* @param {string} field - Field name
* @returns {Promise<string|null>}
*/
async hget(key, field) {
try {
return await this.client.hget(key, field);
} catch (error) {
throw new StorageError(`Failed to get hash field '${field}' from '${key}'`, { error: error.message });
}
}
/**
* Get all hash fields
* @param {string} key - Hash key
* @returns {Promise<Object>}
*/
async hgetall(key) {
try {
return await this.client.hgetall(key);
} catch (error) {
throw new StorageError(`Failed to get all hash fields from '${key}'`, { error: error.message });
}
}
/**
* Set multiple hash fields
* @param {string} key - Hash key
* @param {Object} data - Field-value pairs
* @returns {Promise<string>}
*/
async hmset(key, data) {
try {
return await this.client.hmset(key, data);
} catch (error) {
throw new StorageError(`Failed to set multiple hash fields in '${key}'`, { error: error.message });
}
}
/**
* Add member to set
* @param {string} key - Set key
* @param {string} member - Member to add
* @returns {Promise<number>}
*/
async sadd(key, member) {
try {
return await this.client.sadd(key, member);
} catch (error) {
throw new StorageError(`Failed to add member to set '${key}'`, { error: error.message });
}
}
/**
* Remove member from set
* @param {string} key - Set key
* @param {string} member - Member to remove
* @returns {Promise<number>}
*/
async srem(key, member) {
try {
return await this.client.srem(key, member);
} catch (error) {
throw new StorageError(`Failed to remove member from set '${key}'`, { error: error.message });
}
}
/**
* Get all set members
* @param {string} key - Set key
* @returns {Promise<string[]>}
*/
async smembers(key) {
try {
return await this.client.smembers(key);
} catch (error) {
throw new StorageError(`Failed to get members from set '${key}'`, { error: error.message });
}
}
/**
* Set TTL on key
* @param {string} key - Key
* @param {number} seconds - TTL in seconds
* @returns {Promise<number>}
*/
async expire(key, seconds) {
try {
return await this.client.expire(key, seconds);
} catch (error) {
throw new StorageError(`Failed to set TTL on key '${key}'`, { error: error.message });
}
}
/**
* Increment counter
* @param {string} key - Key
* @returns {Promise<number>}
*/
async incr(key) {
try {
return await this.client.incr(key);
} catch (error) {
throw new StorageError(`Failed to increment key '${key}'`, { error: error.message });
}
}
/**
* Get multiple keys by pattern
* @param {string} pattern - Key pattern (e.g., 'gds:session:*')
* @returns {Promise<string[]>}
*/
async keys(pattern) {
try {
return await this.client.keys(pattern);
} catch (error) {
throw new StorageError(`Failed to get keys matching pattern '${pattern}'`, { error: error.message });
}
}
}
// Export singleton instance
export const storage = new ValkeyStorage();
export default storage;

View File

@@ -0,0 +1,104 @@
import { storage } from './storage.js';
import { logger } from '../utils/logger.js';
import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server';
/**
* Valkey-based Event Store for MCP resumability
* Stores server-sent events for reconnection support
*/
export class ValkeyEventStore implements EventStore {
prefix: string;
ttl: number;
constructor() {
this.prefix = 'mcp:events:';
this.ttl = 3600; // Events expire after 1 hour
}
/**
* Generates a unique event ID for a given stream ID
*/
private generateEventId(streamId: string): number {
return Date.now();
}
/**
* Extracts the stream ID from an event ID
*/
private getStreamIdFromEventId(eventId: string): string {
const parts = eventId.split('_');
return parts.length > 0 ? parts[0]! : '';
}
/**
* Stores an event with a generated event ID
* Implements EventStore.storeEvent
*/
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
logger.debug({ streamId, message }, 'Storing event in ValkeyEventStore');
const eventId = this.generateEventId(streamId);
const key = `${this.prefix}${streamId}`;
const value = JSON.stringify({ eventId, message, timestamp: Date.now() });
// Add to sorted set with event ID as score for ordering
await storage.client.zadd(key, eventId, value);
// Set expiration on the key
await storage.client.expire(key, this.ttl);
return "" + eventId;
}
/**
* Replays events that occurred after a specific event ID
* Implements EventStore.replayEventsAfter
*/
async replayEventsAfter(
lastEventId: string,
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
): Promise<string> {
const streamId = this.getStreamIdFromEventId(lastEventId);
const key = `${this.prefix}${streamId}`;
// Retrieve all events from the sorted set that come after lastEventId
// Using ZRANGEBYSCORE to get events with scores > lastEventId
const events = await storage.client.zrangebyscore(
key,
`(${lastEventId}`, // Exclusive range - events after lastEventId
'+inf' // Up to the highest score
);
if (!events || events.length === 0) {
logger.debug({ streamId, lastEventId }, 'No events to replay');
return lastEventId;
}
let latestEventId = lastEventId;
// Replay each event in order
for (const eventData of events) {
try {
const parsed = JSON.parse(eventData);
const { eventId, message } = parsed;
// Send the event using the provided callback
await send(eventId, message);
latestEventId = eventId;
logger.debug({ streamId, eventId }, 'Event replayed');
} catch (error) {
logger.error({ error, eventData }, 'Failed to replay event');
// Continue with next event even if one fails
}
}
logger.debug(
{ streamId, lastEventId, latestEventId, count: events.length },
'Event replay completed'
);
return latestEventId;
}
}
export default ValkeyEventStore;

244
src/tools/bookings.ts Normal file
View File

@@ -0,0 +1,244 @@
import { storage } from '../session/storage.js';
import { sessionManager } from '../session/manager.js';
import { logger } from '../utils/logger.js';
import {
BookingError,
validatePNR
} from '../utils/errors.js';
import {
validateRequired,
validateNumber
} from '../validation/validators.js';
/**
* Retrieve booking by PNR
* @param {Object} params - Parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Booking details
*/
export async function retrieveBooking(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params, ['pnr']);
// Validate PNR format
validatePNR(params.pnr);
// Retrieve booking from global PNR storage first
const globalBookingKey = `gds:pnr:${params.pnr}`;
let bookingData = await storage.get(globalBookingKey);
// Fallback to session-scoped storage if not found globally
if (!bookingData) {
const bookingKey = sessionManager.getBookingKey(sessionId, params.pnr);
bookingData = await storage.get(bookingKey);
}
if (!bookingData) {
throw new BookingError(`Booking ${params.pnr} not found`);
}
const booking = JSON.parse(bookingData);
logger.info({
type: 'booking_retrieved',
sessionId,
pnr: params.pnr
});
return {
success: true,
booking,
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId, pnr: params.pnr }, 'Booking retrieval failed');
throw error;
}
}
/**
* Cancel booking
* @param {Object} params - Parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Cancellation confirmation
*/
export async function cancelBooking(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params, ['pnr']);
// Validate PNR format
validatePNR(params.pnr);
// Retrieve booking from global PNR storage first
const globalBookingKey = `gds:pnr:${params.pnr}`;
let bookingData = await storage.get(globalBookingKey);
let bookingKey = globalBookingKey;
// Fallback to session-scoped storage if not found globally
if (!bookingData) {
const sessionBookingKey = sessionManager.getBookingKey(sessionId, params.pnr);
bookingData = await storage.get(sessionBookingKey);
bookingKey = sessionBookingKey;
}
if (!bookingData) {
throw new BookingError(`Booking ${params.pnr} not found`);
}
const booking = JSON.parse(bookingData);
// Check if already cancelled
if (booking.status === 'cancelled') {
throw new BookingError('Booking is already cancelled', true);
}
// Update booking status
booking.status = 'cancelled';
booking.lastModified = Date.now();
booking.cancelledAt = Date.now();
// Store updated booking in both locations
await storage.set(bookingKey, JSON.stringify(booking));
// Also update global storage if bookingKey is not already the global key
if (bookingKey !== globalBookingKey) {
await storage.set(globalBookingKey, JSON.stringify(booking));
}
// Remove from session bookings set
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
await storage.srem(bookingsSetKey, params.pnr);
logger.info({
type: 'booking_cancelled',
sessionId,
pnr: params.pnr
});
return {
success: true,
pnr: params.pnr,
status: 'cancelled',
message: `Booking ${params.pnr} has been cancelled successfully`,
cancelledAt: booking.cancelledAt,
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId, pnr: params.pnr }, 'Booking cancellation failed');
throw error;
}
}
/**
* List all bookings in current session
* @param {Object} params - Parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} List of bookings
*/
export async function listBookings(params: any, sessionId: any) {
try {
// Validate pagination parameters
const limit = params.limit || 10;
const offset = params.offset || 0;
validateNumber(limit, 'limit', 1, 100);
validateNumber(offset, 'offset', 0);
// Get all booking PNRs for this session
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
const pnrs = await storage.smembers(bookingsSetKey);
if (pnrs.length === 0) {
return {
success: true,
bookings: [],
total: 0,
limit,
offset,
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
}
// Retrieve booking summaries
const bookingSummaries = [];
for (const pnr of pnrs) {
// Try global storage first
const globalBookingKey = `gds:pnr:${pnr}`;
let bookingData = await storage.get(globalBookingKey);
// Fallback to session-scoped storage
if (!bookingData) {
const bookingKey = sessionManager.getBookingKey(sessionId, pnr);
bookingData = await storage.get(bookingKey);
}
if (bookingData) {
const booking = JSON.parse(bookingData);
// Create summary
const summary = {
pnr: booking.pnr,
status: booking.status,
createdAt: booking.createdAt,
lastModified: booking.lastModified,
totalPrice: booking.totalPrice,
currency: booking.currency,
segments: {
flights: booking.flights.length,
hotels: booking.hotels.length,
cars: booking.cars.length
},
passengerCount: booking.passengers.length
};
bookingSummaries.push(summary);
}
}
// Sort by creation date (newest first)
bookingSummaries.sort((a, b) => b.createdAt - a.createdAt);
// Apply pagination
const paginatedBookings = bookingSummaries.slice(offset, offset + limit);
logger.info({
type: 'bookings_listed',
sessionId,
total: bookingSummaries.length,
returned: paginatedBookings.length
});
return {
success: true,
bookings: paginatedBookings,
total: bookingSummaries.length,
limit,
offset,
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId }, 'Booking list failed');
throw error;
}
}
export default {
retrieveBooking,
cancelBooking,
listBookings
};

270
src/tools/cars.ts Normal file
View File

@@ -0,0 +1,270 @@
/**
* Car Rental Tools
* Implements searchCars and bookCar MCP tools
*/
import { generateCarOptions, getCarById } from '../data/cars.js';
import { generatePNR } from '../data/pnr.js';
import { storage } from '../session/storage.js';
import { logger } from '../utils/logger.js';
import { ValidationError, BookingError } from '../utils/errors.js';
import {
validateRequired,
validateString,
validateFutureDate
} from '../validation/validators.js';
/**
* Search for available car rentals
* @param {Object} params - Search parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Search results
*/
export async function searchCars(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params.pickupLocation, 'pickupLocation');
validateRequired(params.dropoffLocation, 'dropoffLocation');
validateRequired(params.pickupDate, 'pickupDate');
validateRequired(params.dropoffDate, 'dropoffDate');
validateString(params.pickupLocation, 'pickupLocation', { minLength: 3, maxLength: 3 });
validateString(params.dropoffLocation, 'dropoffLocation', { minLength: 3, maxLength: 3 });
validateFutureDate(params.pickupDate, 'pickupDate');
validateFutureDate(params.dropoffDate, 'dropoffDate');
const pickupLocation = params.pickupLocation.toUpperCase();
const dropoffLocation = params.dropoffLocation.toUpperCase();
const pickupDate = new Date(params.pickupDate);
const dropoffDate = new Date(params.dropoffDate);
// Validate date range
if (dropoffDate <= pickupDate) {
throw new ValidationError('dropoffDate must be after pickupDate');
}
const dropoffDateObj = new Date(dropoffDate);
const days = Math.ceil((dropoffDateObj.getTime() - pickupDate.getTime()) / (1000 * 60 * 60 * 24));
if (days < 1) {
throw new ValidationError('Minimum rental period is 1 day');
}
// Generate car options
const options = generateCarOptions(
pickupLocation,
dropoffLocation,
params.pickupDate,
params.dropoffDate
);
logger.info({ sessionId, pickupLocation, dropoffLocation, days, resultCount: options.length }, 'Car search completed');
return {
pickupLocation,
dropoffLocation,
pickupDate: params.pickupDate,
dropoffDate: params.dropoffDate,
days,
results: options
};
} catch (error) {
logger.error({ error, sessionId }, 'Car search failed');
throw error;
}
}
/**
* Book a car rental
* @param {Object} params - Booking parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Booking confirmation
*/
export async function bookCar(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params.carId, 'carId');
validateRequired(params.pickupLocation, 'pickupLocation');
validateRequired(params.dropoffLocation, 'dropoffLocation');
validateRequired(params.pickupDate, 'pickupDate');
validateRequired(params.dropoffDate, 'dropoffDate');
validateRequired(params.driverName, 'driverName');
validateRequired(params.driverEmail, 'driverEmail');
const { carId, pickupLocation, dropoffLocation, pickupDate, dropoffDate, driverName, driverEmail, driverPhone, driverLicense, pnr: existingPnr } = params;
// Validate car exists
const car = getCarById(carId);
if (!car) {
throw new ValidationError(`Car not found: ${carId}`);
}
// Validate dates
validateFutureDate(pickupDate, 'pickupDate');
validateFutureDate(dropoffDate, 'dropoffDate');
const pickup = new Date(pickupDate);
const dropoff = new Date(dropoffDate);
if (dropoff <= pickup) {
throw new ValidationError('dropoffDate must be after pickupDate');
}
const pickupDateObj = new Date(params.pickupDate);
const dropoffDateObj = new Date(params.dropoffDate);
const days = Math.ceil((dropoffDateObj.getTime() - pickupDateObj.getTime()) / (1000 * 60 * 60 * 24));
// Calculate pricing (recalculate with same logic as search)
const oneWayFee = pickupLocation !== dropoffLocation ? 75 : 0;
const dailyRate = car.basePrice; // Simplified - real calculation in generateCarOptions
const totalPrice = (dailyRate * days) + oneWayFee;
// Generate or use existing PNR
const pnr = existingPnr || generatePNR();
const client = storage.client;
// Check if this is adding to existing booking
let booking;
if (existingPnr) {
const existingKey = `gds:session:${sessionId}:booking:${existingPnr}`;
const existingData = await client.get(existingKey);
if (!existingData) {
throw new BookingError(`Booking not found: ${existingPnr}`);
}
booking = JSON.parse(existingData);
// Add car segment
if (!booking.cars) {
booking.cars = [];
}
booking.cars.push({
carId: car.carId,
company: car.company,
category: car.category,
example: car.example,
passengers: car.passengers,
bags: car.bags,
features: car.features,
pickupLocation,
dropoffLocation,
pickupDate,
dropoffDate,
days,
driverName,
driverEmail,
driverPhone,
driverLicense,
pricing: {
dailyRate,
days,
oneWayFee,
totalPrice,
currency: 'USD'
},
status: 'confirmed',
bookedAt: new Date().toISOString()
});
// Recalculate total price
let totalBookingPrice = 0;
if (booking.flights) {
totalBookingPrice += booking.flights.reduce((sum, f) => sum + (f.pricing?.totalPrice || 0), 0);
}
if (booking.hotels) {
totalBookingPrice += booking.hotels.reduce((sum, h) => sum + (h.pricing?.totalPrice || 0), 0);
}
if (booking.cars) {
totalBookingPrice += booking.cars.reduce((sum, c) => sum + (c.pricing?.totalPrice || 0), 0);
}
booking.totalPrice = totalBookingPrice;
booking.updatedAt = new Date().toISOString();
} else {
// Create new car-only booking
booking = {
pnr,
type: 'car',
status: 'confirmed',
cars: [{
carId: car.carId,
company: car.company,
category: car.category,
example: car.example,
passengers: car.passengers,
bags: car.bags,
features: car.features,
pickupLocation,
dropoffLocation,
pickupDate,
dropoffDate,
days,
driverName,
driverEmail,
driverPhone,
driverLicense,
pricing: {
dailyRate,
days,
oneWayFee,
totalPrice,
currency: 'USD'
},
status: 'confirmed',
bookedAt: new Date().toISOString()
}],
totalPrice,
currency: 'USD',
createdAt: new Date().toISOString(),
sessionId
};
}
// Store booking
const bookingKey = `gds:session:${sessionId}:booking:${pnr}`;
await client.set(bookingKey, JSON.stringify(booking), 'EX', 3600);
// Update session bookings set
await client.sadd(`gds:session:${sessionId}:bookings`, pnr);
logger.info({ sessionId, pnr, carId, days }, 'Car rental booking created');
return {
pnr,
status: 'confirmed',
car: {
carId: car.carId,
company: car.company,
category: car.category,
example: car.example,
pickupLocation,
dropoffLocation,
pickupDate,
dropoffDate,
days
},
pricing: {
dailyRate,
days,
oneWayFee,
totalPrice,
currency: 'USD'
},
driverName,
driverEmail,
message: existingPnr
? `Car rental added to existing booking ${pnr}`
: `Car rental confirmed with PNR ${pnr}`
};
} catch (error) {
logger.error({ error, sessionId }, 'Car booking failed');
throw error;
}
}
export default { searchCars, bookCar };

296
src/tools/flights.ts Normal file
View File

@@ -0,0 +1,296 @@
import { generateFlights } from '../data/flights.js';
import { generatePNR } from '../data/pnr.js';
import { storage } from '../session/storage.js';
import { sessionManager } from '../session/manager.js';
import { logger } from '../utils/logger.js';
import {
BookingError,
ValidationError,
validateAirportCode,
validateDate,
validatePNR
} from '../utils/errors.js';
import {
validateRequired,
validateString,
validateNumber,
validateArray,
validateFutureDate,
validatePassenger,
validateCabin
} from '../validation/validators.js';
/**
* Search for available flights
* @param {Object} params - Search parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Search results
*/
export async function searchFlights(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params, ['origin', 'destination', 'departureDate']);
// Validate airport codes
validateAirportCode(params.origin);
validateAirportCode(params.destination);
// Validate same origin/destination
if (params.origin === params.destination) {
throw new ValidationError('Origin and destination must be different');
}
// Validate departure date
validateDate(params.departureDate, 'departureDate');
validateFutureDate(params.departureDate, 'departureDate');
// Validate cabin class if provided
if (params.cabin) {
validateCabin(params.cabin);
}
// Validate passengers
const passengers = params.passengers || { adults: 1, children: 0, infants: 0 };
if (passengers.adults) {
validateNumber(passengers.adults, 'passengers.adults', 1, 9);
}
if (passengers.children) {
validateNumber(passengers.children, 'passengers.children', 0, 9);
}
if (passengers.infants) {
validateNumber(passengers.infants, 'passengers.infants', 0, 9);
}
// Generate flight results
const flights = generateFlights({
origin: params.origin.toUpperCase(),
destination: params.destination.toUpperCase(),
departureDate: params.departureDate,
passengers,
cabin: params.cabin || 'economy'
});
// Increment search counter
await sessionManager.incrementSearchCount(sessionId);
logger.info({
type: 'flight_search',
sessionId,
origin: params.origin,
destination: params.destination,
resultCount: flights.length
});
return {
search: {
origin: params.origin.toUpperCase(),
destination: params.destination.toUpperCase(),
departureDate: params.departureDate,
passengers,
cabin: params.cabin || 'economy'
},
results: flights,
resultCount: flights.length,
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId }, 'Flight search failed');
throw error;
}
}
/**
* Book a flight
* @param {Object} params - Booking parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Booking confirmation
*/
export async function bookFlight(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params, ['flightId', 'passengers']);
// Validate passengers array
validateArray(params.passengers, 'passengers', 1, 9);
// Validate each passenger
for (const passenger of params.passengers) {
validatePassenger(passenger);
}
// Validate contact information
if (params.contactEmail) {
validateString(params.contactEmail, 'contactEmail', 5, 100);
}
if (params.contactPhone) {
validateString(params.contactPhone, 'contactPhone', 10, 20);
}
// Ensure at least one contact method
if (!params.contactEmail && !params.contactPhone) {
// Use first passenger's email if available
if (params.passengers[0].email) {
params.contactEmail = params.passengers[0].email;
} else {
throw new ValidationError('At least one contact method (email or phone) is required');
}
}
// Check if adding to existing PNR
let pnr;
let existingBooking = null;
if (params.pnr) {
// Validate PNR format
validatePNR(params.pnr);
// Retrieve existing booking
const bookingKey = sessionManager.getBookingKey(sessionId, params.pnr);
const bookingData = await storage.get(bookingKey);
if (!bookingData) {
throw new BookingError(`Booking ${params.pnr} not found in current session`);
}
existingBooking = JSON.parse(bookingData);
if (existingBooking.status === 'cancelled') {
throw new BookingError('Cannot add to cancelled booking', true);
}
pnr = params.pnr;
} else {
// Generate new PNR
pnr = generatePNR(sessionId);
}
// Create flight segment from provided details or flight ID
// For mock implementation, we expect the caller to provide the full flight object
// In production, we would retrieve from a database or cache
const flightSegment = params.flightDetails || {
id: params.flightId,
// Basic mock data if details not provided
flightNumber: 'MOCK001',
airlineCode: 'XX',
airlineName: 'Mock Airline',
originCode: 'XXX',
originName: 'Unknown',
destinationCode: 'YYY',
destinationName: 'Unknown',
departureTime: new Date().toISOString(),
arrivalTime: new Date(Date.now() + 3600000).toISOString(),
duration: 60,
aircraftType: 'Mock Aircraft',
cabin: 'economy',
price: 20000, // $200.00
seatsAvailable: 10,
bookingClass: 'Y',
status: 'available'
};
// Create or update booking
const booking = existingBooking || {
pnr,
sessionId,
createdAt: Date.now(),
lastModified: Date.now(),
status: 'confirmed',
passengers: params.passengers,
flights: [],
hotels: [],
cars: [],
totalPrice: 0,
currency: 'USD',
contactEmail: params.contactEmail || '',
contactPhone: params.contactPhone || ''
};
// Add flight segment
booking.flights.push(flightSegment);
booking.lastModified = Date.now();
// Calculate total price
booking.totalPrice = calculateTotalPrice(booking);
// Store booking in both session-scoped AND global storage
const bookingKey = sessionManager.getBookingKey(sessionId, pnr);
await storage.set(bookingKey, JSON.stringify(booking));
// Store in global PNR namespace with TTL (1 hour default)
const globalBookingKey = `gds:pnr:${pnr}`;
const ttlSeconds = parseInt(process.env.PNR_TTL || '3600', 10);
await storage.set(globalBookingKey, JSON.stringify(booking), ttlSeconds);
// Add to session bookings set
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
await storage.sadd(bookingsSetKey, pnr);
// Increment booking counter
await sessionManager.incrementBookingCount(sessionId);
logger.info({
type: 'flight_booked',
sessionId,
pnr,
passengerCount: params.passengers.length
});
return {
success: true,
pnr,
status: booking.status,
booking: {
pnr,
passengers: booking.passengers,
flights: booking.flights,
totalPrice: booking.totalPrice,
currency: booking.currency,
createdAt: booking.createdAt,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone
},
message: `Flight booked successfully. PNR: ${pnr}`,
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId }, 'Flight booking failed');
throw error;
}
}
/**
* Calculate total price across all booking segments
* @param {Object} booking - Booking object
* @returns {number} Total price in cents
*/
function calculateTotalPrice(booking: any) {
let total = 0;
// Sum flight prices
for (const flight of booking.flights) {
total += flight.price || 0;
}
// Sum hotel prices
for (const hotel of booking.hotels) {
total += hotel.price || 0;
}
// Sum car rental prices
for (const car of booking.cars) {
total += car.totalPrice || 0;
}
return total;
}
export default {
searchFlights,
bookFlight
};

290
src/tools/hotels.ts Normal file
View File

@@ -0,0 +1,290 @@
/**
* Hotel Search and Booking Tools
* Implements searchHotels and bookHotel MCP tools
*/
import { getHotelsByCity, getHotelById, generateHotelPrice } from '../data/hotels.js';
import { generatePNR } from '../data/pnr.js';
import { storage } from '../session/storage.js';
import { logger } from '../utils/logger.js';
import { ValidationError, BookingError } from '../utils/errors.js';
import {
validateRequired,
validateString,
validateNumber,
validateFutureDate
} from '../validation/validators.js';
/**
* Search for available hotels
* @param {Object} params - Search parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Search results
*/
export async function searchHotels(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params.cityCode, 'cityCode');
validateRequired(params.checkInDate, 'checkInDate');
validateRequired(params.checkOutDate, 'checkOutDate');
validateString(params.cityCode, 'cityCode', { minLength: 3, maxLength: 3 });
validateFutureDate(params.checkInDate, 'checkInDate');
validateFutureDate(params.checkOutDate, 'checkOutDate');
if (params.guests) {
validateNumber(params.guests, 'guests', { min: 1, max: 10 });
}
const cityCode = params.cityCode.toUpperCase();
const checkInDate = new Date(params.checkInDate);
const checkOutDate = new Date(params.checkOutDate);
const guests = params.guests || 1;
// Validate date range
if (checkOutDate <= checkInDate) {
throw new ValidationError('checkOutDate must be after checkInDate');
}
const checkInDateObj = new Date(checkInDate);
const checkOutDateObj = new Date(checkOutDate);
const nights = Math.ceil((checkOutDateObj.getTime() - checkInDateObj.getTime()) / (1000 * 60 * 60 * 24));
if (nights < 1) {
throw new ValidationError('Minimum stay is 1 night');
}
// Get hotels for city
const cityHotels = getHotelsByCity(cityCode);
if (cityHotels.length === 0) {
return {
cityCode,
checkInDate: params.checkInDate,
checkOutDate: params.checkOutDate,
nights,
results: [],
message: `No hotels found for city code ${cityCode}`
};
}
// Select 5-10 hotels and generate pricing
const numResults = Math.min(cityHotels.length, Math.floor(Math.random() * 6) + 5);
const selectedHotels = cityHotels
.sort(() => Math.random() - 0.5)
.slice(0, numResults);
const results = selectedHotels.map(hotel => {
const pricing = generateHotelPrice(hotel.basePrice, nights, params.checkInDate);
return {
hotelId: hotel.id,
name: hotel.name,
chain: hotel.chain,
stars: hotel.stars,
address: hotel.address,
amenities: hotel.amenities,
pricing: {
pricePerNight: pricing.pricePerNight,
nights: pricing.nights,
totalPrice: pricing.totalPrice,
currency: 'USD'
},
availability: 'available' // Mock: always available
};
});
logger.info({ sessionId, cityCode, nights, resultCount: results.length }, 'Hotel search completed');
return {
cityCode,
checkInDate: params.checkInDate,
checkOutDate: params.checkOutDate,
nights,
guests,
results
};
} catch (error) {
logger.error({ error, sessionId }, 'Hotel search failed');
throw error;
}
}
/**
* Book a hotel
* @param {Object} params - Booking parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Booking confirmation
*/
export async function bookHotel(params: any, sessionId: any) {
try {
// Validate required fields
validateRequired(params.hotelId, 'hotelId');
validateRequired(params.checkInDate, 'checkInDate');
validateRequired(params.checkOutDate, 'checkOutDate');
validateRequired(params.guestName, 'guestName');
validateRequired(params.guestEmail, 'guestEmail');
const { hotelId, checkInDate, checkOutDate, guestName, guestEmail, guestPhone, pnr: existingPnr, roomType } = params;
// Validate hotel exists
const hotel = getHotelById(hotelId);
if (!hotel) {
throw new ValidationError(`Hotel not found: ${hotelId}`);
}
// Validate dates
validateFutureDate(checkInDate, 'checkInDate');
validateFutureDate(checkOutDate, 'checkOutDate');
const checkIn = new Date(checkInDate);
const checkOut = new Date(checkOutDate);
if (checkOut <= checkIn) {
throw new ValidationError('checkOutDate must be after checkInDate');
}
const checkInDateObj = new Date(params.checkInDate);
const checkOutDateObj = new Date(params.checkOutDate);
const nights = Math.ceil((checkOutDateObj.getTime() - checkInDateObj.getTime()) / (1000 * 60 * 60 * 24));
// Calculate pricing
const pricing = generateHotelPrice(hotel.basePrice, nights, checkInDate);
// Generate or use existing PNR
const pnr = existingPnr || generatePNR();
const client = storage.client;
// Check if this is adding to existing booking
let booking;
if (existingPnr) {
const existingKey = `gds:session:${sessionId}:booking:${existingPnr}`;
const existingData = await client.get(existingKey);
if (!existingData) {
throw new BookingError(`Booking not found: ${existingPnr}`);
}
booking = JSON.parse(existingData);
// Add hotel segment
if (!booking.hotels) {
booking.hotels = [];
}
booking.hotels.push({
hotelId: hotel.id,
name: hotel.name,
chain: hotel.chain,
stars: hotel.stars,
address: hotel.address,
checkInDate,
checkOutDate,
nights,
roomType: roomType || 'Standard',
guestName,
guestEmail,
guestPhone,
pricing: {
pricePerNight: pricing.pricePerNight,
nights: pricing.nights,
totalPrice: pricing.totalPrice,
currency: 'USD'
},
status: 'confirmed',
bookedAt: new Date().toISOString()
});
// Recalculate total price
let totalPrice = 0;
if (booking.flights) {
totalPrice += booking.flights.reduce((sum, f) => sum + (f.pricing?.totalPrice || 0), 0);
}
if (booking.hotels) {
totalPrice += booking.hotels.reduce((sum, h) => sum + (h.pricing?.totalPrice || 0), 0);
}
if (booking.cars) {
totalPrice += booking.cars.reduce((sum, c) => sum + (c.pricing?.totalPrice || 0), 0);
}
booking.totalPrice = totalPrice;
booking.updatedAt = new Date().toISOString();
} else {
// Create new hotel-only booking
booking = {
pnr,
type: 'hotel',
status: 'confirmed',
hotels: [{
hotelId: hotel.id,
name: hotel.name,
chain: hotel.chain,
stars: hotel.stars,
address: hotel.address,
checkInDate,
checkOutDate,
nights,
roomType: roomType || 'Standard',
guestName,
guestEmail,
guestPhone,
pricing: {
pricePerNight: pricing.pricePerNight,
nights: pricing.nights,
totalPrice: pricing.totalPrice,
currency: 'USD'
},
status: 'confirmed',
bookedAt: new Date().toISOString()
}],
totalPrice: pricing.totalPrice,
currency: 'USD',
createdAt: new Date().toISOString(),
sessionId
};
}
// Store booking
const bookingKey = `gds:session:${sessionId}:booking:${pnr}`;
await client.set(bookingKey, JSON.stringify(booking), 'EX', 3600);
// Update session bookings set
await client.sadd(`gds:session:${sessionId}:bookings`, pnr);
logger.info({ sessionId, pnr, hotelId, nights }, 'Hotel booking created');
return {
pnr,
status: 'confirmed',
hotel: {
hotelId: hotel.id,
name: hotel.name,
chain: hotel.chain,
stars: hotel.stars,
checkInDate,
checkOutDate,
nights,
roomType: roomType || 'Standard'
},
pricing: {
pricePerNight: pricing.pricePerNight,
nights: pricing.nights,
totalPrice: pricing.totalPrice,
currency: 'USD'
},
guestName,
guestEmail,
message: existingPnr
? `Hotel added to existing booking ${pnr}`
: `Hotel booking confirmed with PNR ${pnr}`
};
} catch (error) {
logger.error({ error, sessionId }, 'Hotel booking failed');
throw error;
}
}
export default { searchHotels, bookHotel };

161
src/tools/session.ts Normal file
View File

@@ -0,0 +1,161 @@
import { storage } from '../session/storage.js';
import { sessionManager } from '../session/manager.js';
import { logger } from '../utils/logger.js';
/**
* Get session information
* @param {Object} params - Parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Session information
*/
export async function getSessionInfo(params: any, sessionId: any) {
try {
// Get session data
const session = await sessionManager.getSession(sessionId);
// Calculate uptime
const now = Date.now();
const uptimeMs = now - session.createdAt;
const uptime = formatUptime(uptimeMs);
// Get booking count from session bookings set
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
const pnrs = await storage.smembers(bookingsSetKey);
const totalBookings = pnrs.length;
// Calculate time until expiry
const ttlSeconds = Math.max(0, Math.floor((session.expiresAt - now) / 1000));
logger.info({
type: 'session_info_retrieved',
sessionId,
bookingCount: totalBookings,
uptime
});
return {
success: true,
session: {
sessionId,
createdAt: new Date(session.createdAt).toISOString(),
lastActivity: new Date(session.lastActivity).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
ttlSeconds,
uptime,
uptimeMs,
bookingCount: totalBookings,
searchCount: session.searchCount
},
metadata: {
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId }, 'Failed to retrieve session info');
throw error;
}
}
/**
* Clear session data (remove all bookings)
* @param {Object} params - Parameters
* @param {string} sessionId - Session identifier
* @returns {Promise<Object>} Clear confirmation
*/
export async function clearSession(params: any, sessionId: any) {
try {
// Validate confirmation parameter
const confirmed = params.confirm === true;
if (!confirmed) {
return {
success: false,
message: 'Session clear requires confirmation. Set confirm=true to proceed.',
warning: 'This will delete all bookings in the current session.',
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
}
// Get all booking PNRs for this session
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
const pnrs = await storage.smembers(bookingsSetKey);
let deletedCount = 0;
// Delete all bookings from both session-scoped and global storage
for (const pnr of pnrs) {
// Delete from session-scoped storage
const sessionBookingKey = sessionManager.getBookingKey(sessionId, pnr);
await storage.del(sessionBookingKey);
// Delete from global storage
const globalBookingKey = `gds:pnr:${pnr}`;
await storage.del(globalBookingKey);
deletedCount++;
}
// Clear the bookings set
await storage.del(bookingsSetKey);
// Reset booking count
const sessionKey = sessionManager.getSessionKey(sessionId);
await storage.hset(sessionKey, 'bookingCount', '0');
// Preserve sessionId and createdAt (don't delete session itself)
logger.info({
type: 'session_cleared',
sessionId,
deletedBookings: deletedCount
});
return {
success: true,
message: `Session cleared successfully. ${deletedCount} booking(s) deleted.`,
deletedBookings: deletedCount,
preservedData: {
sessionId,
createdAt: 'preserved',
searchCount: 'preserved'
},
metadata: {
sessionId,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error({ error, sessionId }, 'Failed to clear session');
throw error;
}
}
/**
* Format uptime duration as human-readable string
* @param {number} ms - Milliseconds
* @returns {string} Formatted uptime
*/
function formatUptime(ms: any) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`;
} else if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
export default {
getSessionInfo,
clearSession
};

15
src/transports/factory.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createHTTPServer } from './http-server.js';
import { logger } from '../utils/logger.js';
/**
* Transport factory - creates Streamable HTTP transport
* HTTP/1.1 + SSE per MCP specification 2025-06-18
*/
export async function createTransport(mcpServer, _options = {}) {
logger.info({ transport: 'http' }, 'Creating MCP Streamable HTTP transport');
// Always use Streamable HTTP (HTTP/1.1 + SSE per MCP specification 2025-06-18)
return await createHTTPServer(mcpServer);
}
export default { createTransport };

View File

@@ -0,0 +1,232 @@
import { logger } from '../utils/logger.js';
import { corsMiddleware } from '../middleware/cors.js';
import { rateLimitMiddleware } from '../middleware/rate-limit.js';
import { protocolVersionMiddleware } from '../middleware/protocol-version.js';
import { requestLoggerMiddleware } from '../middleware/logger.js';
import { messageNormalizationMiddleware } from '../middleware/message-normalization.js';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { isInitializeRequest } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { ValkeyEventStore } from '../session/valkey-event-store.js';
import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '127.0.0.1';
/**
* Create and configure Streamable HTTP server per MCP specification 2025-06-18
* Transport: HTTP/1.1 + Server-Sent Events (SSE)
*
* Uses STATELESS mode to support multiple concurrent client initializations.
* Each client can send their own initialize request without server-side session tracking.
*/
export async function createHTTPServer(mcpServer: any) {
const app = createMcpExpressApp();
const transports: Record<string, any> = {};
// Apply CORS globally
app.use(corsMiddleware);
// Health check endpoint - NO auth required
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'gds-mock-mcp',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});
const mcpPostHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
logger.debug({ sessionId, body: req.body }, 'Received MCP request for session');
} else {
logger.info('No session ID provided in MCP request');
}
try {
let transport: NodeStreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request
const eventStore = new ValkeyEventStore();
transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore, // Enable resumability
onsessioninitialized: sessionId => {
// Store the transport by session ID when session is initialized
// This avoids race conditions where requests might come in before the session is stored
logger.info(`Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
}
});
// Set up onclose handler to clean up transport when closed
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
logger.info(`Transport closed for session ${sid}, removing from transports map`);
delete transports[sid];
}
};
// Set up transport error handler to log SDK errors
transport.onerror = (error) => {
console.error('Transport error:', error);
logger.error(
{
error: error.message,
stack: error.stack
},
'MCP Transport error'
);
};
// Connect the transport to the MCP server BEFORE handling the request
// so responses can flow back through the same transport
const server = mcpServer;
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return; // Already handled
} else if (sessionId) {
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32_001, message: 'Session not found' },
id: null
});
return;
} else {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32_000, message: 'Bad Request: Session ID required' },
id: null
});
return;
}
// Handle the request with existing transport - no need to reconnect
// The existing transport is already connected to the server
await transport.handleRequest(req, res, req.body);
} catch (error) {
logger.error({ error }, 'Error handling MCP request:');
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32_603,
message: 'Internal server error'
},
id: null
});
}
}
};
app.post('/mcp', mcpPostHandler);
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
const mcpGetHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId) {
res.status(400).send('Missing session ID');
return;
}
if (!transports[sessionId]) {
res.status(404).send('Session not found');
return;
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
logger.info(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
logger.info(`Establishing new SSE stream for session ${sessionId}`);
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', mcpGetHandler);
// Handle DELETE requests for session termination (according to MCP spec)
const mcpDeleteHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId) {
res.status(400).send('Missing session ID');
return;
}
if (!transports[sessionId]) {
res.status(404).send('Session not found');
return;
}
logger.info(`Received session termination request for session ${sessionId}`);
try {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined },
'Error handling session termination:'
);
if (!res.headersSent) {
res.status(500).send('Error processing session termination');
}
}
};
app.delete('/mcp', mcpDeleteHandler);
// Handle server shutdown
process.on('SIGINT', async () => {
logger.info('Shutting down server...');
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
logger.info(`Closing transport for session ${sessionId}`);
await transports[sessionId].close();
delete transports[sessionId];
} catch (error) {
logger.error(
{ sessionId, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined },
`Error closing transport for session ${sessionId}`
);
}
}
logger.info('Server shutdown complete');
process.exit(0);
});
// MCP endpoint with middleware - applied to /mcp route specifically
// Note: express.json() already parsed the body, so req.body is available
app.use('/mcp', requestLoggerMiddleware);
app.use('/mcp', messageNormalizationMiddleware); // Ensure params field exists
app.use('/mcp', protocolVersionMiddleware);
app.use('/mcp', rateLimitMiddleware);
// Start HTTP server
const httpServer = app.listen(PORT, HOST, () => {
logger.info({ host: HOST, port: PORT }, 'Streamable HTTP server listening');
});
// Graceful shutdown handler
const shutdown = async () => {
logger.info('Shutting down HTTP server...');
httpServer.close(() => {
logger.info('HTTP server closed');
});
};
return { httpServer, shutdown };
}
export default { createHTTPServer };

203
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* MCP Error Codes and Error Handling Utilities
* Based on JSON-RPC 2.0 and MCP specification
*/
/**
* Standard MCP/JSON-RPC 2.0 error codes
*/
export const ErrorCode = {
// JSON-RPC 2.0 standard errors
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
// MCP-specific errors (custom range -32000 to -32099)
INVALID_SESSION: -32001,
SESSION_EXPIRED: -32002,
INVALID_AIRPORT_CODE: -32003,
INVALID_DATE: -32004,
BOOKING_NOT_FOUND: -32005,
BOOKING_CANCELLED: -32006,
INVALID_PASSENGER: -32007,
STORAGE_ERROR: -32008,
VALIDATION_ERROR: -32009,
INVALID_PNR_FORMAT: -32010
};
/**
* Base MCP Error class
*/
export class MCPError extends Error {
code: number;
data: any;
constructor(message: string, code: number, data: any = null) {
super(message);
this.name = 'MCPError';
this.code = code;
this.data = data;
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
code: this.code,
message: this.message,
data: this.data
};
}
}
/**
* Validation Error - Invalid input parameters
*/
export class ValidationError extends MCPError {
constructor(message, data = null) {
super(message, ErrorCode.VALIDATION_ERROR, data);
this.name = 'ValidationError';
}
}
/**
* Session Error - Invalid or expired session
*/
export class SessionError extends MCPError {
constructor(message, expired = false) {
const code = expired ? ErrorCode.SESSION_EXPIRED : ErrorCode.INVALID_SESSION;
super(message, code);
this.name = 'SessionError';
}
}
/**
* Booking Error - Booking not found or already cancelled
*/
export class BookingError extends MCPError {
constructor(message, cancelled = false) {
const code = cancelled ? ErrorCode.BOOKING_CANCELLED : ErrorCode.BOOKING_NOT_FOUND;
super(message, code);
this.name = 'BookingError';
}
}
/**
* Storage Error - Valkey/database operation failed
*/
export class StorageError extends MCPError {
constructor(message, data = null) {
super(message, ErrorCode.STORAGE_ERROR, data);
this.name = 'StorageError';
}
}
/**
* Format error response for MCP
* @param {Error} error - Error object
* @returns {Object} MCP error response
*/
export function formatErrorResponse(error: any) {
if (error instanceof MCPError) {
return {
error: {
code: error.code,
message: error.message,
data: error.data
}
};
}
// Fallback for unknown errors
return {
error: {
code: ErrorCode.INTERNAL_ERROR,
message: error.message || 'Internal server error',
data: {
type: error.name,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
}
}
};
}
/**
* Validate airport code (3-letter IATA)
* @param {string} code - Airport code
* @throws {ValidationError} If code is invalid
*/
export function validateAirportCode(code: any) {
if (!code || typeof code !== 'string' || !/^[A-Z]{3}$/.test(code)) {
throw new ValidationError(
`Invalid airport code '${code}': must be 3-letter IATA code (e.g., JFK, LAX)`,
{ code, expected: 'XXX' }
);
}
}
/**
* Validate date format (ISO 8601: YYYY-MM-DD)
* @param {string} date - Date string
* @param {string} fieldName - Field name for error messages
* @throws {ValidationError} If date is invalid
*/
export function validateDate(date, fieldName = 'date') {
if (!date || typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new ValidationError(
`Invalid ${fieldName} '${date}': must be ISO 8601 date (YYYY-MM-DD)`,
{ date, expected: 'YYYY-MM-DD' }
);
}
const parsed = new Date(date);
if (isNaN(parsed.getTime())) {
throw new ValidationError(
`Invalid ${fieldName} '${date}': not a valid date`,
{ date }
);
}
}
/**
* Validate email format
* @param {string} email - Email address
* @throws {ValidationError} If email is invalid
*/
export function validateEmail(email: any) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !emailRegex.test(email)) {
throw new ValidationError(
`Invalid email address '${email}'`,
{ email }
);
}
}
/**
* Validate PNR format (TEST-XXXXXX)
* @param {string} pnr - PNR code
* @throws {ValidationError} If PNR format is invalid
*/
export function validatePNR(pnr: any) {
if (!pnr || typeof pnr !== 'string' || !/^TEST-[A-Z0-9]{6}$/.test(pnr)) {
throw new ValidationError(
`Invalid PNR format '${pnr}': must be TEST-XXXXXX (e.g., TEST-ABC123)`,
{ pnr, expected: 'TEST-XXXXXX' }
);
}
}
export default {
ErrorCode,
MCPError,
ValidationError,
SessionError,
BookingError,
StorageError,
formatErrorResponse,
validateAirportCode,
validateDate,
validateEmail,
validatePNR
};

89
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,89 @@
import pino from 'pino';
/**
* Configure and export Pino structured logger
* LOG_LEVEL env var: silent, error, warn, info, debug, trace (default: info)
*/
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
export const logger = pino({
level: LOG_LEVEL,
transport: {
target: 'pino/file',
options: {
destination: process.stdout.fd
}
},
formatters: {
level: (label) => {
return { level: label.toUpperCase() };
}
},
timestamp: pino.stdTimeFunctions.isoTime,
base: {
service: 'gds-mock-mcp',
environment: process.env.NODE_ENV || 'development'
}
});
/**
* Create child logger with additional context
* @param {Object} bindings - Additional context fields
* @returns {Object} Child logger instance
*/
export function createLogger(bindings: any) {
return logger.child(bindings);
}
/**
* Log MCP tool call
* @param {string} tool - Tool name
* @param {Object} params - Tool parameters
* @param {string} sessionId - Session identifier
*/
export function logToolCall(tool: any, params: any, sessionId: any) {
logger.info({
type: 'tool_call',
tool,
sessionId,
params
});
}
/**
* Log MCP tool response
* @param {string} tool - Tool name
* @param {number} duration - Response time in ms
* @param {string} sessionId - Session identifier
* @param {boolean} success - Whether operation succeeded
* @param {Object} metadata - Additional response metadata
*/
export function logToolResponse(tool, duration, sessionId, success, metadata = {}) {
logger.info({
type: 'tool_response',
tool,
sessionId,
duration,
success,
...metadata
});
}
/**
* Log error with context
* @param {Error} error - Error object
* @param {Object} context - Error context
*/
export function logError(error, context = {}) {
logger.error({
type: 'error',
error: {
message: error.message,
stack: error.stack,
code: error.code
},
...context
});
}
export default logger;

322
src/validation/schemas.ts Normal file
View File

@@ -0,0 +1,322 @@
/**
* MCP Tool JSON Schemas
* Based on contracts/mcp-tools.md
*/
export const searchFlightsSchema = {
type: 'object',
properties: {
origin: {
type: 'string',
pattern: '^[A-Z]{3}$',
description: 'Origin airport IATA code (3 letters, e.g., JFK)'
},
destination: {
type: 'string',
pattern: '^[A-Z]{3}$',
description: 'Destination airport IATA code (3 letters, e.g., LAX)'
},
departureDate: {
type: 'string',
format: 'date',
description: 'Departure date in ISO 8601 format (YYYY-MM-DD)'
},
passengers: {
type: 'object',
properties: {
adults: {
type: 'integer',
minimum: 1,
maximum: 9,
default: 1
},
children: {
type: 'integer',
minimum: 0,
maximum: 9,
default: 0
},
infants: {
type: 'integer',
minimum: 0,
maximum: 9,
default: 0
}
},
required: ['adults']
},
cabin: {
type: 'string',
enum: ['economy', 'premium_economy', 'business', 'first'],
default: 'economy'
}
},
required: ['origin', 'destination', 'departureDate']
};
export const bookFlightSchema = {
type: 'object',
properties: {
flightId: {
type: 'string',
description: 'Flight identifier from search results'
},
passengers: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
firstName: {
type: 'string',
minLength: 1,
maxLength: 50,
pattern: '^[A-Za-z\\s\\-]+$'
},
lastName: {
type: 'string',
minLength: 1,
maxLength: 50,
pattern: '^[A-Za-z\\s\\-]+$'
},
type: {
type: 'string',
enum: ['adult', 'child', 'infant']
},
dateOfBirth: {
type: 'string',
format: 'date',
description: 'Date of birth (optional)'
},
email: {
type: 'string',
format: 'email',
description: 'Email address (optional)'
},
phone: {
type: 'string',
description: 'Phone number (optional)'
}
},
required: ['firstName', 'lastName', 'type']
}
},
contactEmail: {
type: 'string',
format: 'email'
},
contactPhone: {
type: 'string'
},
pnr: {
type: 'string',
description: 'Existing PNR to add flight to (optional)'
}
},
required: ['flightId', 'passengers']
};
export const searchHotelsSchema = {
type: 'object',
properties: {
cityCode: {
type: 'string',
pattern: '^[A-Z]{3}$',
description: 'City IATA code (3 letters)'
},
checkInDate: {
type: 'string',
format: 'date'
},
checkOutDate: {
type: 'string',
format: 'date'
},
guests: {
type: 'integer',
minimum: 1,
maximum: 10,
default: 1
},
starRating: {
type: 'integer',
minimum: 1,
maximum: 5,
description: 'Minimum star rating (optional)'
}
},
required: ['cityCode', 'checkInDate', 'checkOutDate']
};
export const bookHotelSchema = {
type: 'object',
properties: {
hotelId: {
type: 'string',
description: 'Hotel identifier from search results'
},
guests: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
firstName: {
type: 'string',
minLength: 1,
maxLength: 50
},
lastName: {
type: 'string',
minLength: 1,
maxLength: 50
},
email: {
type: 'string',
format: 'email'
}
},
required: ['firstName', 'lastName']
}
},
contactEmail: {
type: 'string',
format: 'email'
},
contactPhone: {
type: 'string'
},
pnr: {
type: 'string',
description: 'Existing PNR to add hotel to (optional)'
}
},
required: ['hotelId', 'guests']
};
export const searchCarsSchema = {
type: 'object',
properties: {
pickupLocation: {
type: 'string',
pattern: '^[A-Z]{3}$',
description: 'Pickup location airport code'
},
pickupDate: {
type: 'string',
format: 'date-time'
},
dropoffLocation: {
type: 'string',
pattern: '^[A-Z]{3}$',
description: 'Dropoff location airport code'
},
dropoffDate: {
type: 'string',
format: 'date-time'
},
vehicleClass: {
type: 'string',
enum: ['economy', 'compact', 'midsize', 'fullsize', 'suv', 'luxury'],
description: 'Preferred vehicle class (optional)'
}
},
required: ['pickupLocation', 'pickupDate', 'dropoffLocation', 'dropoffDate']
};
export const bookCarSchema = {
type: 'object',
properties: {
carId: {
type: 'string',
description: 'Car rental identifier from search results'
},
driver: {
type: 'object',
properties: {
firstName: {
type: 'string',
minLength: 1,
maxLength: 50
},
lastName: {
type: 'string',
minLength: 1,
maxLength: 50
},
email: {
type: 'string',
format: 'email'
},
phone: {
type: 'string'
},
licenseNumber: {
type: 'string',
description: 'Driver license number (optional)'
}
},
required: ['firstName', 'lastName']
},
pnr: {
type: 'string',
description: 'Existing PNR to add car to (optional)'
}
},
required: ['carId', 'driver']
};
export const retrieveBookingSchema = {
type: 'object',
properties: {
pnr: {
type: 'string',
pattern: '^TEST-[A-Z0-9]{6}$',
description: 'Booking reference (e.g., TEST-ABC123)'
}
},
required: ['pnr']
};
export const cancelBookingSchema = {
type: 'object',
properties: {
pnr: {
type: 'string',
pattern: '^TEST-[A-Z0-9]{6}$',
description: 'Booking reference to cancel'
}
},
required: ['pnr']
};
export const listBookingsSchema = {
type: 'object',
properties: {
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 10,
description: 'Maximum number of bookings to return'
},
offset: {
type: 'integer',
minimum: 0,
default: 0,
description: 'Number of bookings to skip'
}
}
};
export default {
searchFlightsSchema,
bookFlightSchema,
searchHotelsSchema,
bookHotelSchema,
searchCarsSchema,
bookCarSchema,
retrieveBookingSchema,
cancelBookingSchema,
listBookingsSchema
};

View File

@@ -0,0 +1,194 @@
import { ValidationError } from '../utils/errors.js';
/**
* JSON Schema Validators using native JavaScript validation
* Based on contracts/mcp-tools.md specifications
*/
/**
* Validate required fields presence
* @param {Object} obj - Object to validate
* @param {string[]} requiredFields - Array of required field names
* @throws {ValidationError} If any required field is missing
*/
export function validateRequired(obj: any, requiredFields: any) {
for (const field of requiredFields) {
if (obj[field] === undefined || obj[field] === null || obj[field] === '') {
throw new ValidationError(`Missing required field: ${field} in ${JSON.stringify(obj)}`);
}
}
}
/**
* Validate string field
* @param {*} value - Value to validate
* @param {string} fieldName - Field name for error messages
* @param {number} minLength - Minimum length (optional)
* @param {number} maxLength - Maximum length (optional)
* @param {RegExp} pattern - Pattern to match (optional)
* @throws {ValidationError} If validation fails
*/
export function validateString(value, fieldName, minLength = null, maxLength = null, pattern = null) {
if (typeof value !== 'string') {
throw new ValidationError(`Field '${fieldName}' must be a string`);
}
if (minLength !== null && value.length < minLength) {
throw new ValidationError(`Field '${fieldName}' must be at least ${minLength} characters`);
}
if (maxLength !== null && value.length > maxLength) {
throw new ValidationError(`Field '${fieldName}' must be at most ${maxLength} characters`);
}
if (pattern !== null && !pattern.test(value)) {
throw new ValidationError(`Field '${fieldName}' does not match required pattern`);
}
}
/**
* Validate number field
* @param {*} value - Value to validate
* @param {string} fieldName - Field name for error messages
* @param {number} min - Minimum value (optional)
* @param {number} max - Maximum value (optional)
* @throws {ValidationError} If validation fails
*/
export function validateNumber(value, fieldName, min = null, max = null) {
if (typeof value !== 'number' || isNaN(value)) {
throw new ValidationError(`Field '${fieldName}' must be a number`);
}
if (min !== null && value < min) {
throw new ValidationError(`Field '${fieldName}' must be at least ${min}`);
}
if (max !== null && value > max) {
throw new ValidationError(`Field '${fieldName}' must be at most ${max}`);
}
}
/**
* Validate enum field
* @param {*} value - Value to validate
* @param {string} fieldName - Field name for error messages
* @param {string[]} allowedValues - Array of allowed values
* @throws {ValidationError} If validation fails
*/
export function validateEnum(value: any, fieldName: any, allowedValues: any) {
if (!allowedValues.includes(value)) {
throw new ValidationError(
`Field '${fieldName}' must be one of: ${allowedValues.join(', ')}. Got: ${value}`
);
}
}
/**
* Validate array field
* @param {*} value - Value to validate
* @param {string} fieldName - Field name for error messages
* @param {number} minItems - Minimum number of items (optional)
* @param {number} maxItems - Maximum number of items (optional)
* @throws {ValidationError} If validation fails
*/
export function validateArray(value, fieldName, minItems = null, maxItems = null) {
if (!Array.isArray(value)) {
throw new ValidationError(`Field '${fieldName}' must be an array`);
}
if (minItems !== null && value.length < minItems) {
throw new ValidationError(`Field '${fieldName}' must have at least ${minItems} items`);
}
if (maxItems !== null && value.length > maxItems) {
throw new ValidationError(`Field '${fieldName}' must have at most ${maxItems} items`);
}
}
/**
* Validate date comparison (date1 < date2)
* @param {string} date1 - First date (ISO 8601)
* @param {string} date2 - Second date (ISO 8601)
* @param {string} field1Name - First field name
* @param {string} field2Name - Second field name
* @throws {ValidationError} If date1 >= date2
*/
export function validateDateOrder(date1: any, date2: any, field1Name: any, field2Name: any) {
const d1 = new Date(date1);
const d2 = new Date(date2);
if (d1 >= d2) {
throw new ValidationError(
`${field1Name} must be before ${field2Name} (${date1} >= ${date2})`
);
}
}
/**
* Validate date is not in the past
* @param {string} date - Date to validate (ISO 8601)
* @param {string} fieldName - Field name for error messages
* @throws {ValidationError} If date is in the past
*/
export function validateFutureDate(date: any, fieldName: any) {
const inputDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (inputDate < today) {
throw new ValidationError(
`${fieldName} cannot be in the past (${date})`
);
}
}
/**
* Validate passenger object
* @param {Object} passenger - Passenger data
* @throws {ValidationError} If validation fails
*/
export function validatePassenger(passenger: any) {
validateRequired(passenger, ['firstName', 'lastName', 'type']);
validateString(passenger.firstName, 'firstName', 1, 50, /^[A-Za-z\s\-]+$/);
validateString(passenger.lastName, 'lastName', 1, 50, /^[A-Za-z\s\-]+$/);
validateEnum(passenger.type, 'type', ['adult', 'child', 'infant']);
if (passenger.email) {
validateString(passenger.email, 'email', 5, 100);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(passenger.email)) {
throw new ValidationError('Invalid email format');
}
}
if (passenger.phone) {
validateString(passenger.phone, 'phone', 10, 20);
}
if (passenger.dateOfBirth) {
validateString(passenger.dateOfBirth, 'dateOfBirth', 10, 10, /^\d{4}-\d{2}-\d{2}$/);
}
}
/**
* Validate cabin class
* @param {string} cabin - Cabin class
* @throws {ValidationError} If validation fails
*/
export function validateCabin(cabin: any) {
const validCabins = ['economy', 'premium_economy', 'business', 'first'];
validateEnum(cabin, 'cabin', validCabins);
}
export default {
validateRequired,
validateString,
validateNumber,
validateEnum,
validateArray,
validateDateOrder,
validateFutureDate,
validatePassenger,
validateCabin
};