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

42
.env.example Normal file
View File

@@ -0,0 +1,42 @@
# ==============================================
# MCP Server Configuration
# ==============================================
# Logging configuration
LOG_LEVEL=info # Options: silent, error, warn, info, debug, trace
NODE_ENV=production
# ==============================================
# Valkey (Redis-compatible) Configuration
# ==============================================
VALKEY_HOST=localhost
VALKEY_PORT=6379
VALKEY_PASSWORD=
VALKEY_DB=0
# ==============================================
# Session Management
# ==============================================
# Session timeout in seconds (default: 3600 = 1 hour)
MCP_SESSION_TIMEOUT=3600
# ==============================================
# Mock Data Configuration
# ==============================================
# Data generation seed
# Options: random (default), fixed (deterministic), demo (curated demo data)
MOCK_DATA_SEED=random
# Response delay simulation (milliseconds)
# Set to 0 to disable artificial delays
MOCK_RESPONSE_DELAY=0
# ==============================================
# Docker-specific Configuration
# ==============================================
# When running in Docker, use service name as host
# VALKEY_HOST=valkey

View File

@@ -229,6 +229,10 @@ When you see handoff suggestions, route the user to the appropriate next command
## Active Technologies ## Active Technologies
- Node.js 20 LTS (current stable) + Minimal libraries - MCP SDK (@modelcontextprotocol/sdk), Valkey client (ioredis), Docker buildx for multi-platform builds (001-mock-gds-server) - Node.js 20 LTS (current stable) + Minimal libraries - MCP SDK (@modelcontextprotocol/sdk), Valkey client (ioredis), Docker buildx for multi-platform builds (001-mock-gds-server)
- Valkey 8.0+ (Redis-compatible in-memory store with persistence) (001-mock-gds-server) - Valkey 8.0+ (Redis-compatible in-memory store with persistence) (001-mock-gds-server)
- Node.js 20 LTS (ES modules) + @modelcontextprotocol/sdk ^1.0.4 (MCP protocol), ioredis ^5.4.1 (Valkey client), pino ^9.5.0 (structured logging), HTTP/2 server library (TBD in research phase) (001-mock-gds-server)
- Valkey (Redis-compatible) for session state, PNR storage with TTL, rate limiting counters (001-mock-gds-server)
- Valkey 8.0+ (Redis-compatible in-memory store with persistence for PNRs and sessions) (001-mock-gds-server)
- Node.js 20 LTS with TypeScript 6.0.2 + @modelcontextprotocol/sdk ^1.0.4 (MCP protocol), @modelcontextprotocol/server ^2.0.0-alpha.2 (McpServer), @modelcontextprotocol/express ^2.0.0-alpha.2 (HTTP transport), ioredis ^5.4.1 (Valkey client), Express ^5.2.1, pino ^9.5.0 (structured logging) (001-mock-gds-server)
## Recent Changes ## Recent Changes
- 001-mock-gds-server: Added Node.js 20 LTS (current stable) + Minimal libraries - MCP SDK (@modelcontextprotocol/sdk), Valkey client (ioredis), Docker buildx for multi-platform builds - 001-mock-gds-server: Added Node.js 20 LTS (current stable) + Minimal libraries - MCP SDK (@modelcontextprotocol/sdk), Valkey client (ioredis), Docker buildx for multi-platform builds

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Node.js
node_modules/
npm-debug.log
yarn-error.log
pnpm-debug.log
lerna-debug.log
.npm
.eslintcache
# Build outputs
dist/
build/
*.tsbuildinfo
# Environment
.env
.env*.local
.env.production
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Test coverage
coverage/
.nyc_output/
# Logs
logs/
*.log
# Docker
.dockerignore
# Temporary files
tmp/
temp/
*.tmp

10
.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
# Prettier ignore patterns
node_modules/
dist/
build/
coverage/
package-lock.json
yarn.lock
pnpm-lock.yaml
*.min.js
.git/

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"printWidth": 100,
"arrowParens": "always",
"endOfLine": "lf"
}

102
CHANGELOG.md Normal file
View File

@@ -0,0 +1,102 @@
# Changelog
All notable changes to the GDS Mock MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-04-08
### Added
- Initial release of GDS Mock MCP Server
- **Flight Search and Booking** (User Story 1 - MVP)
- `searchFlights` tool: Search for flights between airports with realistic pricing
- `bookFlight` tool: Create flight bookings with passenger details
- Mock data: 100+ airports, 30+ airlines, deterministic flight generation
- Support for economy, business, and first class cabins
- Realistic pricing ($200-$2500+ based on route and class)
- **Session Management** (User Story 4)
- Concurrent session support with UUID v4 session IDs
- Session isolation: bookings scoped to sessions with zero data leakage
- Session TTL management (1 hour default, configurable)
- Activity tracking and automatic cleanup
- **Remote Access** (User Story 6)
- **MCP 2025-11-25 Streamable HTTP transport** (HTTP/1.1 + SSE)
- Protocol version validation middleware (strict 2025-11-25)
- IP-based rate limiting (100 req/min default, Valkey-backed)
- CORS middleware with permissive wildcard policy
- Health check endpoint (`/health`)
- CLI arguments: `--remote`, `--port`, `--host`, `--verbose`, `--log-level`
- Dual transport support: stdio (default) or HTTP
- SSE polling pattern with Last-Event-ID resumption
- Example remote client demonstrating SSE connection
- Nginx reverse proxy configuration template
- **Hotel Search and Bundling** (User Story 2)
- `searchHotels` tool: Search hotels by city with pricing tiers
- `bookHotel` tool: Create hotel bookings and bundle with flights
- Mock data: 50+ properties across major cities (3-5 star ratings)
- Realistic pricing: budget ($80-$150), midrange ($150-$300), luxury ($300-$800)
- Multi-service bundling: combine hotels with flights under single PNR
- **Car Rental Integration** (User Story 3)
- `searchCars` tool: Search car rentals by pickup/dropoff location
- `bookCar` tool: Create car rental bookings and bundle with flights/hotels
- Mock data: 8 rental companies, 8 vehicle categories
- Complete travel package support: flights + hotels + cars under one PNR
- **Booking Management Tools**
- `retrieveBooking` tool: Fetch booking details by PNR
- `cancelBooking` tool: Cancel bookings with status validation
- `listBookings` tool: List all bookings in current session
- `getSessionInfo` tool: View session metadata
- `clearSession` tool: Clear session data
- **Infrastructure**
- Valkey 8.0+ integration for session and PNR storage
- Structured logging with Pino
- MCP error codes and error handling
- JSON schema validation
- PNR generation with TEST- prefix (base32 encoding)
- Docker Compose configuration for Valkey
- Multi-stage Dockerfile with non-root user
- ESLint and Prettier configuration
### Technical Details
- **Node.js 20 LTS** with ES modules
- **@modelcontextprotocol/sdk v1.0.4+** for MCP protocol
- **ioredis v5.4.1** for Valkey/Redis client
- **Pino v9.5.0** for structured logging
- **Express v4.18+** for HTTP middleware
- **express-rate-limit v7.1+** for IP-based rate limiting
- **CORS v2.8+** for cross-origin support
### Security
- PNRs prefixed with TEST- to prevent confusion with production
- No real external API calls (100% self-contained)
- Rate limiting on remote access (100 req/min per IP)
- Localhost binding by default (127.0.0.1) per MCP security
- Non-root Docker user
- Session isolation with cryptographically secure UUIDs
### Documentation
- Comprehensive README with installation and usage instructions
- SAFETY_DISCLAIMER.md emphasizing testing/demo purpose only
- Inline JSDoc comments for all public functions
- Example remote client with SSE demonstration
- Nginx configuration template for optional HTTP/2 upgrade
## [Unreleased]
### Planned
- Additional search filters (airline preference, hotel amenities, car features)
- Enhanced error messages with suggested corrections
- Metrics and monitoring endpoints
- Load testing and performance optimization
- Additional mock data for global coverage
---
**Note**: This is a mock server for testing and demonstration purposes only. All data is simulated. No real travel bookings are created.

261
QUICKSTART.md Normal file
View File

@@ -0,0 +1,261 @@
# Quick Start Guide - GDS Mock MCP Server
## Prerequisites
- Node.js 20 LTS
- Docker and Docker Compose (recommended)
- Valkey 8.0+ or Redis
## Running the Server
### Option 1: Docker Compose (Recommended)
```bash
# Start all services (GDS server + Valkey)
docker compose up -d
# Check status
docker compose ps
# View logs
docker compose logs -f gds-mock-mcp
# Stop services
docker compose down
```
### Option 2: Local Development
```bash
# Start Valkey
docker run -d --name valkey -p 6379:6379 valkey/valkey:8-alpine
# Start the HTTP server
node src/index.js
# Server will listen on http://127.0.0.1:3000
```
## Testing the Server
### Health Check
```bash
curl http://localhost:3000/health
```
Expected response:
```json
{
"status": "healthy",
"service": "gds-mock-mcp",
"uptime": 10.5,
"timestamp": "2026-04-08T03:00:00.000Z"
}
```
### MCP Initialize
First, initialize an MCP session to get a session ID:
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-d '{
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
}'
```
The response will include a session ID in the `mcp-session-id` header. Extract this for subsequent requests.
Example response:
```
< mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
data: {"jsonrpc":"2.0","result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"gds-mock-mcp","version":"0.1.0"}},"id":1}
```
### List Available Tools
Use the session ID from the initialize response:
```bash
# Replace SESSION_ID with the actual session ID from initialization
SESSION_ID="550e8400-e29b-41d4-a716-446655440000"
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-H "mcp-session-id: $SESSION_ID" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 2,
"params": {}
}'
```
### Search for Flights
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-H "mcp-session-id: $SESSION_ID" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 3,
"params": {
"name": "searchFlights",
"arguments": {
"origin": "LAX",
"destination": "JFK",
"departureDate": "2026-05-15",
"passengers": 1
}
}
}'
```
## Available Tools
The server provides 11 MCP tools:
### Flight Operations
- `searchFlights` - Search for available flights
- `bookFlight` - Book a flight with passenger details
### Hotel Operations
- `searchHotels` - Search for hotels by city
- `bookHotel` - Book a hotel room
### Car Rental Operations
- `searchCars` - Search for rental cars
- `bookCar` - Book a rental car
### Booking Management
- `retrieveBooking` - Get booking details by PNR
- `cancelBooking` - Cancel an existing booking
- `listBookings` - List all bookings in current session
### Session Management
- `getSessionInfo` - Get current session information
- `clearSession` - Clear all bookings from session
## Environment Variables
### Server Configuration
- `PORT` - HTTP server port (default: `3000`)
- `HOST` - HTTP server host (default: `127.0.0.1`, use `0.0.0.0` for Docker)
- `LOG_LEVEL` - Logging level (`trace`, `debug`, `info`, `warn`, `error`, default: `info`)
- `NODE_ENV` - Environment (`development` or `production`, default: `development`)
### Valkey/Redis Configuration
- `VALKEY_HOST` - Valkey host (default: `localhost`)
- `VALKEY_PORT` - Valkey port (default: `6379`)
- `VALKEY_PASSWORD` - Valkey password (optional)
- `VALKEY_DB` - Valkey database number (default: `0`)
### Rate Limiting & CORS
- `RATE_LIMIT_MAX` - Max requests per minute (default: `100`)
- `CORS_ORIGINS` - Allowed CORS origins (comma-separated, default: `*`)
## Command Line Arguments
```bash
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 (sets LOG_LEVEL=debug)
--log-level <level> Set log level (trace|debug|info|warn|error)
--help Show help message
```
## Example Usage
### Start HTTP server on custom port
```bash
node src/index.js --port 8080 --host 0.0.0.0
```
### Start with debug logging
```bash
node src/index.js --verbose
```
### Docker with custom port
```bash
PORT=8080 docker compose up -d
```
## Troubleshooting
### Server won't start
1. Check Valkey is running: `docker ps | grep valkey`
2. Check port is available: `lsof -i :3000`
3. Check logs: `docker compose logs gds-mock-mcp`
### "Connection refused" errors
- Ensure Valkey is running on port 6379
- Check `VALKEY_HOST` environment variable
### "Empty reply from server"
- This error was fixed in the latest version
- Ensure you're using the updated code
### Rate limit errors (429)
- Default: 100 requests per minute per IP
- Wait 60 seconds or adjust rate limit in `src/middleware/rate-limit.js`
## Development
### Install dependencies
```bash
npm install
```
### Run linter
```bash
npm run lint
```
### Build Docker image
```bash
npm run docker:build
```
## Safety Notes
⚠️ **This is a MOCK server for testing and demonstration purposes only.**
- All PNRs are prefixed with `TEST-` to prevent production confusion
- Uses in-memory Valkey for session storage (data is not persistent)
- CORS is configured with wildcard (`*`) for development
- Not suitable for production without security enhancements
For production use, implement:
- Real GDS API integrations (Amadeus, Sabre, Travelport)
- Proper authentication and authorization
- TLS/SSL encryption
- Persistent database storage
- Restricted CORS policy
- Rate limiting based on authenticated users
---
**Version**: 0.1.0
**MCP Protocol**: 2025-11-25
**Last Updated**: 2026-04-08

509
README.md Normal file
View File

@@ -0,0 +1,509 @@
# Mock GDS MCP Server
A **Remote MCP** (Model Context Protocol) server that simulates Global Distribution System (GDS) functionality for testing and demonstration purposes. Uses **MCP 2025-11-25 Streamable HTTP** transport (HTTP/1.1 + Server-Sent Events).
## ⚠️ IMPORTANT SAFETY NOTICE
**THIS IS A MOCK SERVER FOR TESTING AND DEMONSTRATION ONLY**
- ❌ Does NOT connect to real GDS systems
- ❌ Does NOT process real bookings or transactions
- ❌ Does NOT charge credit cards or process payments
- ✅ All bookings are simulated with `TEST-` prefixed PNRs
- ✅ Safe for development, testing, and training environments
See [SAFETY_DISCLAIMER.md](./SAFETY_DISCLAIMER.md) for complete details.
---
## Features
- **Remote MCP Access**: HTTP/1.1 + SSE transport per MCP 2025-11-25 specification
- **Flight Operations**: Search flights, create bookings, retrieve confirmations
- **Hotel Bookings**: Search and book hotels with realistic pricing
- **Car Rentals**: Search and book rental cars from major providers
- **Multi-Service Bundling**: Combined bookings under single PNR
- **Session Management**: Isolated sessions with UUID v4 IDs and TTL
- **Rate Limiting**: 100 requests/minute per IP (Valkey-backed)
- **Realistic Mock Data**: Valid IATA codes, realistic pricing, professional presentation
- **Docker Ready**: Multi-platform container images (amd64/arm64)
## Quick Start
### Prerequisites
- Node.js 20+ LTS
- Docker and Docker Compose (recommended)
- Valkey 8.0+ or Redis (for storage backend)
### Installation
#### Option 1: Docker Deployment (Recommended)
```bash
# Clone repository
git clone <repository-url>
cd gds-mock-mcp
# Build and start all services
docker compose up -d
# Verify health
curl http://localhost:3000/health
```
#### Option 2: Local Development
```bash
# Install dependencies
npm install
# Start Valkey
docker run -d --name valkey -p 6379:6379 valkey/valkey:8-alpine
# Start HTTP server
node src/index.js
```
### Testing the Server
```bash
# Check health
curl http://localhost:3000/health
# Initialize MCP session
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-d '{
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}
}'
```
**Environment Variables:**
You can customize deployment with environment variables:
```bash
# Custom port
PORT=8080 docker compose up -d
# Change log level
LOG_LEVEL=debug docker compose up -d
# Bind to specific host
HOST=0.0.0.0 docker compose up -d
```
## MCP Tools
The server provides **11 MCP tools** for travel booking operations:
### Flight Operations
- `searchFlights` - Search for available flights
- `bookFlight` - Book a flight with passenger details
### Hotel Operations
- `searchHotels` - Search for hotels by city
- `bookHotel` - Book a hotel room
### Car Rental Operations
- `searchCars` - Search for rental cars
- `bookCar` - Book a rental car
### Booking Management
- `retrieveBooking` - Get booking details by PNR
- `cancelBooking` - Cancel an existing booking
- `listBookings` - List all bookings in session
### Session Management
- `getSessionInfo` - Get current session information
- `clearSession` - Clear all bookings from session
## Usage Examples
### Health Check
```bash
curl http://localhost:3000/health
```
### 1. Initialize MCP Session
First, initialize to get a session ID:
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-d '{
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}
}'
```
**Extract the session ID from the response headers:**
```
< mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
```
### 2. List Available Tools
```bash
# Use the session ID from step 1
SESSION_ID="550e8400-e29b-41d4-a716-446655440000"
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-H "mcp-session-id: $SESSION_ID" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 2,
"params": {}
}'
```
### 3. Search Flights
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-protocol-version: 2025-11-25" \
-H "mcp-session-id: $SESSION_ID" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 3,
"params": {
"name": "searchFlights",
"arguments": {
"origin": "JFK",
"destination": "LAX",
"departureDate": "2026-05-01",
"passengers": 1
}
}
}'
```
## Architecture
### Remote MCP Transport
- **Protocol**: MCP 2025-11-25 Streamable HTTP
- **Transport**: HTTP/1.1 + Server-Sent Events (SSE)
- **Endpoint**: `/mcp` for all MCP operations
- **Session Management**: UUID v4 session IDs with 1-hour TTL
### Production Deployment
For production use, consider using Nginx as a reverse proxy to add:
- TLS/SSL encryption (HTTPS)
- HTTP/2 support for clients
- Additional rate limiting
- Request logging
See `docker/nginx.conf.example` for a sample Nginx configuration.
## Usage
### MCP Tool: searchFlights
Search for available flights between two airports.
**Input:**
```json
{
"origin": "JFK",
"destination": "LAX",
"departureDate": "2026-06-15",
"passengers": { "adults": 2 },
"cabin": "economy"
}
```
**Output:**
```json
{
"search": { "origin": "JFK", "destination": "LAX", "departureDate": "2026-06-15" },
"results": [
{
"id": "flight-0-1234567890",
"flightNumber": "AA123",
"airlineCode": "AA",
"airlineName": "American Airlines",
"originCode": "JFK",
"destinationCode": "LAX",
"departureTime": "2026-06-15T08:00:00",
"arrivalTime": "2026-06-15T11:30:00",
"duration": 390,
"cabin": "economy",
"price": 29900,
"seatsAvailable": 15,
"bookingClass": "Y",
"status": "available"
}
],
"resultCount": 5
}
```
### MCP Tool: bookFlight
Create a flight booking.
**Input:**
```json
{
"flightId": "flight-0-1234567890",
"passengers": [
{
"firstName": "John",
"lastName": "Doe",
"type": "adult",
"email": "john.doe@example.com"
}
],
"contactEmail": "john.doe@example.com",
"flightDetails": { /* full flight object from search */ }
}
```
**Output:**
```json
{
"success": true,
"pnr": "TEST-A1B2C3",
"status": "confirmed",
"message": "Flight booked successfully. PNR: TEST-A1B2C3",
"booking": {
"pnr": "TEST-A1B2C3",
"passengers": [...],
"flights": [...],
"totalPrice": 29900,
"currency": "USD"
}
}
```
### MCP Tool: retrieveBooking
Retrieve booking details by PNR.
**Input:**
```json
{
"pnr": "TEST-A1B2C3"
}
```
### MCP Tool: cancelBooking
Cancel an existing booking.
**Input:**
```json
{
"pnr": "TEST-A1B2C3"
}
```
### MCP Tool: listBookings
List all bookings in current session.
**Input:**
```json
{
"limit": 10,
"offset": 0
}
```
## Configuration
Configuration is managed via environment variables. Copy `.env.example` to `.env` and customize:
```bash
cp .env.example .env
```
### Key Configuration Options
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_LEVEL` | `info` | Logging verbosity (silent, error, warn, info, debug, trace) |
| `VALKEY_HOST` | `localhost` | Valkey/Redis host |
| `VALKEY_PORT` | `6379` | Valkey/Redis port |
| `MCP_SESSION_TIMEOUT` | `3600` | Session timeout in seconds (1 hour) |
| `MOCK_DATA_SEED` | `random` | Data generation mode (random, fixed, demo) |
## Architecture
```
┌─────────────────┐
│ MCP Client │
│ (CLI/Agent) │
└────────┬────────┘
│ stdio
┌─────────────────┐
│ GDS Mock MCP │
│ Server │
│ (Node.js 20) │
└────────┬────────┘
┌─────────────────┐
│ Valkey 8.0+ │
│ (Storage) │
└─────────────────┘
```
## Project Structure
```
├── src/
│ ├── index.js # Entry point
│ ├── server.js # MCP server
│ ├── tools/ # MCP tool handlers
│ │ ├── flights.js # Flight operations
│ │ └── bookings.js # Booking management
│ ├── data/ # Mock data generators
│ │ ├── airports.js # Airport data (100+ airports)
│ │ ├── airlines.js # Airline data (30+ carriers)
│ │ ├── flights.js # Flight generator
│ │ └── pnr.js # PNR generation
│ ├── session/ # Session management
│ │ ├── manager.js # Session lifecycle
│ │ └── storage.js # Valkey client
│ ├── validation/ # Input validation
│ └── utils/ # Utilities (logging, errors)
├── docker/
│ ├── Dockerfile # Multi-stage build
│ └── docker-bake.hcl # Multi-platform config
├── tests/ # Test suite
└── specs/ # Design documents
```
## Development
### Running Tests
```bash
npm test # All tests
npm run test:unit # Unit tests only
npm run test:integration # Integration tests only
```
### Code Quality
```bash
npm run lint # Check code style
npm run lint:fix # Fix code style issues
npm run format # Format with Prettier
npm run format:check # Check formatting
```
### Building Docker Images
```bash
# Build for all platforms
npm run docker:build
# Or use buildx directly
docker buildx bake -f docker/docker-bake.hcl
# Build for specific platform
docker buildx bake -f docker/docker-bake.hcl amd64
docker buildx bake -f docker/docker-bake.hcl arm64
```
## Troubleshooting
### Server Won't Start
**Problem:** Server exits immediately after starting
**Solution:**
1. Check Valkey is running: `docker-compose ps valkey`
2. Verify connection: `docker-compose logs valkey`
3. Check environment variables: `cat .env`
### Connection Refused
**Problem:** `ECONNREFUSED` error connecting to Valkey
**Solution:**
1. Ensure Valkey service is healthy: `docker-compose ps`
2. Check network connectivity: `docker network ls`
3. Verify `VALKEY_HOST` matches service name in docker-compose
### Session Expired Errors
**Problem:** `Session expired` errors during testing
**Solution:**
1. Increase `MCP_SESSION_TIMEOUT` in `.env`
2. Ensure consistent session ID usage
3. Check system time synchronization
### PNR Not Found
**Problem:** `Booking not found` when retrieving by PNR
**Solution:**
1. Verify PNR format: `TEST-XXXXXX`
2. Confirm session ID matches booking session
3. Check Valkey data persistence: `docker-compose logs valkey`
## Performance
- **Search Response Time**: < 100ms (mock data generation)
- **Booking Creation**: < 200ms (Valkey write)
- **Concurrent Sessions**: 50+ simultaneous MCP connections
- **Session Capacity**: 1000+ bookings per session
- **Memory Footprint**: ~50MB (Node.js + dependencies)
## Contributing
This is a mock server for testing purposes. Contributions welcome for:
- Additional airline/airport data
- Enhanced mock data realism
- Performance optimizations
- Bug fixes
## License
MIT License - See LICENSE file for details
## Support
For issues and questions:
- GitHub Issues: <repository-url>/issues
- Documentation: See `/specs` directory for detailed design docs
---
**Remember**: This is a MOCK server. All bookings are simulated. Never use in production systems requiring real GDS connectivity.

188
SAFETY_DISCLAIMER.md Normal file
View File

@@ -0,0 +1,188 @@
# SAFETY DISCLAIMER
## ⚠️ FOR TESTING AND DEMONSTRATION PURPOSES ONLY ⚠️
This Mock GDS MCP Server is designed **EXCLUSIVELY** for:
- ✅ Software testing and quality assurance
- ✅ Developer training and education
- ✅ Sales demonstrations and proof-of-concept
- ✅ Integration testing with MCP clients
- ✅ Automated test suite execution
---
## What This Server Does NOT Do
### ❌ NO Real GDS Connections
- This server does NOT connect to any real Global Distribution Systems
- Does NOT communicate with Amadeus, SABRE, Galileo, or any production GDS
- Does NOT access real flight inventory, hotel availability, or car rental systems
- All data is generated locally from mock data files
### ❌ NO Real Transactions
- Does NOT process real bookings
- Does NOT charge credit cards or payment instruments
- Does NOT create actual travel reservations
- Does NOT send confirmation emails to airlines or travel suppliers
- Does NOT issue real tickets or vouchers
### ❌ NO Real Passenger Data
- Does NOT store or transmit real passenger PII (Personally Identifiable Information)
- Does NOT connect to passenger databases
- Does NOT perform identity verification
- All passenger data is ephemeral and session-scoped
### ❌ NO Production Use
- This server is NOT certified or approved for production use
- Does NOT provide SLA guarantees or uptime commitments
- Does NOT handle real customer transactions
- Does NOT meet regulatory compliance requirements for real bookings
---
## What This Server DOES Do
### ✅ Mock Data Generation
- Generates realistic flight, hotel, and car rental options
- Uses valid IATA airport codes and airline identifiers
- Simulates pricing, availability, and schedule data
- Creates professional-looking mock responses
### ✅ Test-Prefixed Identifiers
- **ALL PNRs (Passenger Name Records) have `TEST-` prefix**
- Format: `TEST-XXXXXX` (e.g., `TEST-A1B2C3`)
- This prefix clearly identifies all bookings as test/mock data
- Production systems should REJECT any PNR starting with `TEST-`
### ✅ Session Isolation
- Each MCP session maintains isolated booking state
- Sessions auto-expire after timeout (default 1 hour)
- Data is stored in Valkey (Redis-compatible) with TTL
- No cross-session data leakage
### ✅ Full MCP Protocol Compliance
- Implements Model Context Protocol specification correctly
- Provides proper tool schemas and validation
- Returns standard error codes and responses
- Works with any MCP-compliant client
---
## Safety Guarantees
### 1. No External API Calls
**Guarantee**: This server makes ZERO external API calls to production systems.
**Verification**:
- Review `src/data/` directory - all data is embedded
- Check network logs - no outbound HTTP/HTTPS connections
- Inspect Docker network configuration - isolated network only
### 2. Test-Only PNR Format
**Guarantee**: All PNR codes follow `TEST-XXXXXX` format.
**Verification**:
- See `src/data/pnr.js` - `generatePNR()` function enforces prefix
- Validation in `src/utils/errors.js` - `validatePNR()` requires `TEST-` prefix
- Production systems should have safeguards to reject `TEST-` prefixed bookings
### 3. Local Data Storage Only
**Guarantee**: All booking data stays within the Valkey container.
**Verification**:
- Check `docker-compose.yaml` - Valkey not exposed publicly
- Review `src/session/storage.js` - only connects to local Valkey
- Data persists only during container lifetime (configurable)
### 4. Non-Root Container Execution
**Guarantee**: Docker container runs as non-root user for security.
**Verification**:
- See `docker/Dockerfile` - creates `gds` user (UID 1001)
- Container runs with `USER gds` directive
- Cannot write to host system outside mounted volumes
---
## Configuration Safety
### Safe Defaults
The server ships with safe default configuration:
```bash
MOCK_DATA_SEED=random # Non-deterministic mock data
LOG_LEVEL=info # Appropriate logging verbosity
MCP_SESSION_TIMEOUT=3600 # 1-hour session expiry
VALKEY_HOST=localhost # Local-only connection
```
### Unsafe Configurations (Don't Do This!)
**DO NOT** set `VALKEY_HOST` to a production database
**DO NOT** modify PNR generation to remove `TEST-` prefix
**DO NOT** connect this server to real GDS credentials
**DO NOT** use this server in customer-facing production systems
---
## Regulatory Compliance
### This Server Is NOT:
- PCI DSS compliant (does not handle real payment data)
- GDPR compliant (not designed for real personal data)
- IATA NDC certified (not a real distribution system)
- SOC 2 audited (no production security controls)
### Use Cases That Require Real GDS:
If you need any of the following, use a **real GDS system**, not this mock server:
- Real flight bookings for customers
- Actual hotel reservations
- Live car rental confirmations
- Ticketing and payment processing
- Customer itinerary management
- Regulatory reporting and auditing
---
## Liability Disclaimer
**NO WARRANTY**: This software is provided "as is" without warranty of any kind.
**NO LIABILITY**: The authors are not liable for:
- Loss of data
- Failed bookings or reservations
- Financial losses
- Service interruptions
- Any damages arising from use or misuse
**USER RESPONSIBILITY**: Users are responsible for:
- Ensuring appropriate use (testing only)
- Not using in production systems
- Complying with applicable laws and regulations
- Properly disclaiming mock data in demonstrations
---
## Acknowledgment
By using this Mock GDS MCP Server, you acknowledge that:
1. ✅ I understand this is a mock/test server only
2. ✅ I will NOT use this in production systems
3. ✅ I will NOT process real customer bookings
4. ✅ I will clearly label all demonstrations as "mock data"
5. ✅ I accept full responsibility for appropriate use
---
## Questions or Concerns?
If you have questions about safe and appropriate use of this mock server:
- Review the documentation in `/specs` directory
- Check the README.md for usage examples
- File an issue on the project repository
- Consult with your legal and compliance teams before use
---
**Last Updated**: 2026-04-07
**Version**: 0.1.0
**Status**: TEST/DEMO USE ONLY

62
docker-compose.yaml Normal file
View File

@@ -0,0 +1,62 @@
services:
valkey:
image: valkey/valkey:8.0-alpine
container_name: gds-mock-valkey
ports:
- "6379:6379"
volumes:
- valkey_data:/data
command: >
valkey-server
--save 60 1
--loglevel warning
--maxmemory 256mb
--maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stopped
networks:
- gds-network
gds-mock-mcp:
build:
context: .
dockerfile: docker/Dockerfile
container_name: gds-mock-mcp
depends_on:
valkey:
condition: service_healthy
ports:
- "${PORT:-3000}:3000" # HTTP port for MCP Streamable HTTP transport
environment:
- NODE_ENV=production
- LOG_LEVEL=${LOG_LEVEL:-info}
- VALKEY_HOST=valkey
- VALKEY_PORT=6379
- MCP_SESSION_TIMEOUT=${MCP_SESSION_TIMEOUT:-3600}
- MOCK_DATA_SEED=${MOCK_DATA_SEED:-random}
- PORT=3000
- HOST=0.0.0.0 # Bind to all interfaces in Docker
restart: unless-stopped
networks:
- gds-network
stdin_open: true
tty: true
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
volumes:
valkey_data:
driver: local
networks:
gds-network:
driver: bridge

49
docker/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Stage 2: Production
FROM node:20-alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S gds && \
adduser -S -D -H -u 1001 -h /app -s /sbin/nologin -G gds -g gds gds
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder --chown=gds:gds /app/node_modules ./node_modules
# Copy application source
COPY --chown=gds:gds src ./src
COPY --chown=gds:gds package.json ./
# Set environment variables
ENV NODE_ENV=production \
LOG_LEVEL=info \
VALKEY_HOST=valkey \
VALKEY_PORT=6379
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "const Redis = require('ioredis'); const client = new Redis({ host: process.env.VALKEY_HOST, port: process.env.VALKEY_PORT, lazyConnect: true }); client.connect().then(() => client.ping()).then(() => process.exit(0)).catch(() => process.exit(1));"
# Switch to non-root user
USER gds
# Expose MCP server (stdio, no network port)
# MCP servers use stdio transport, not network ports
# Start server with dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "src/index.js"]

21
docker/docker-bake.hcl Normal file
View File

@@ -0,0 +1,21 @@
target "default" {
context = ".."
dockerfile = "docker/Dockerfile"
platforms = ["linux/amd64", "linux/arm64"]
tags = [
"gds-mock-mcp:latest",
"gds-mock-mcp:0.1.0"
]
}
target "amd64" {
inherits = ["default"]
platforms = ["linux/amd64"]
tags = ["gds-mock-mcp:0.1.0-amd64"]
}
target "arm64" {
inherits = ["default"]
platforms = ["linux/arm64"]
tags = ["gds-mock-mcp:0.1.0-arm64"]
}

75
docker/nginx.conf.example Normal file
View File

@@ -0,0 +1,75 @@
# Nginx Configuration for Optional HTTP/2 Upgrade
# Client-facing: HTTP/2
# Backend: HTTP/1.1 (MCP server)
upstream gds_mcp_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name gds-mock-mcp.example.com;
# SSL configuration (replace with your certificates)
ssl_certificate /path/to/ssl/certificate.crt;
ssl_certificate_key /path/to/ssl/private.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Logging
access_log /var/log/nginx/gds-mcp-access.log;
error_log /var/log/nginx/gds-mcp-error.log;
# MCP endpoint proxy
location /mcp {
proxy_pass http://gds_mcp_backend;
proxy_http_version 1.1;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# MCP headers
proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;
proxy_set_header MCP-Session-Id $http_mcp_session_id;
proxy_set_header Last-Event-ID $http_last_event_id;
# SSE configuration
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Health check endpoint
location /health {
proxy_pass http://gds_mcp_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
}
# Rate limiting (additional layer beyond application)
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=100r/m;
limit_req zone=mcp_limit burst=20 nodelay;
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
}
# HTTP redirect to HTTPS
server {
listen 80;
server_name gds-mock-mcp.example.com;
return 301 https://$server_name$request_uri;
}

43
eslint.config.js Normal file
View File

@@ -0,0 +1,43 @@
export default [
{
ignores: [
'node_modules/',
'dist/',
'build/',
'coverage/',
'*.min.js',
'.git/',
'docker/'
]
},
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: {
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly'
}
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'off',
'prefer-const': 'error',
'no-var': 'error',
'eqeqeq': ['error', 'always'],
'curly': ['error', 'all'],
'brace-style': ['error', '1tbs'],
'comma-dangle': ['error', 'never'],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'always']
}
}
];

3105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "gds-mock-mcp",
"version": "0.1.0",
"description": "Mock Global Distribution System (GDS) MCP Server for testing and demonstration",
"type": "module",
"main": "dist/index.js",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts --verbose --log-level DEBUG",
"start:js": "node src/index.ts",
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
"lint": "eslint .",
"format": "prettier --write .",
"docker:build": "docker build -t gds-mock-mcp -f docker/Dockerfile .",
"docker:run": "docker-compose up",
"docker:down": "docker-compose down"
},
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@modelcontextprotocol/express": "^2.0.0-alpha.2",
"@modelcontextprotocol/node": "^2.0.0-alpha.2",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"ioredis": "^5.4.1",
"pino": "^9.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
},
"keywords": [
"mcp",
"gds",
"mock",
"travel",
"booking",
"testing"
],
"author": "",
"license": "MIT"
}

View File

@@ -393,3 +393,354 @@ All tools return standard MCP errors:
- Timestamps use ISO 8601 format - Timestamps use ISO 8601 format
- Prices in USD cents (integer) - Prices in USD cents (integer)
- All PNRs prefixed with "TEST-" for safety - All PNRs prefixed with "TEST-" for safety
---
## Remote Access Endpoints (Added 2026-04-07)
The following endpoints are available when running in HTTP/2 remote transport mode.
### Health Check Endpoint
**Endpoint**: `GET /health`
**Description**: Returns server operational status for monitoring and health checks. This endpoint is unauthenticated and exempt from rate limiting.
**Use Cases**:
- Load balancer health checks
- Monitoring system integration (Prometheus, Datadog, etc.)
- Deployment validation
- Debugging connection and memory issues
**HTTP Method**: GET
**Authentication**: None (publicly accessible)
**Rate Limiting**: Exempt (not subject to rate limits)
**Request**: No body required
**Response Schema**:
```json
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["healthy", "degraded", "unhealthy"],
"description": "Overall server health status"
},
"uptime": {
"type": "integer",
"description": "Server uptime in seconds since start"
},
"version": {
"type": "string",
"description": "Server version (from package.json)"
},
"connections": {
"type": "object",
"properties": {
"stdio": {
"type": "integer",
"description": "Active stdio connections (0 or 1)"
},
"http": {
"type": "integer",
"description": "Active HTTP/2 connections"
},
"total": {
"type": "integer",
"description": "Total active connections"
}
},
"required": ["stdio", "http", "total"]
},
"sessions": {
"type": "object",
"properties": {
"active": {
"type": "integer",
"description": "Sessions with recent activity (within 5 minutes)"
},
"total": {
"type": "integer",
"description": "Total sessions in storage"
}
},
"required": ["active", "total"]
},
"storage": {
"type": "object",
"properties": {
"connected": {
"type": "boolean",
"description": "Valkey connection status"
},
"responseTime": {
"type": "number",
"description": "Valkey ping response time in milliseconds"
}
},
"required": ["connected", "responseTime"]
},
"memory": {
"type": "object",
"properties": {
"used": {
"type": "number",
"description": "Process memory used in MB"
},
"total": {
"type": "number",
"description": "Total system memory in MB"
},
"percentage": {
"type": "number",
"description": "Memory usage percentage (0-1)"
}
},
"required": ["used", "total", "percentage"]
},
"timestamp": {
"type": "integer",
"description": "Health check timestamp (Unix milliseconds)"
}
},
"required": ["status", "uptime", "version", "connections", "sessions", "storage", "memory", "timestamp"]
}
```
**Status Determination**:
- **healthy** (200 OK): All systems operational
- Storage connected
- Memory usage < 80%
- Valkey response time < 100ms
- **degraded** (200 OK): Some issues but still serving requests
- Storage slow (ping 100-500ms)
- Memory usage 80-90%
- **unhealthy** (503 Service Unavailable): Critical issues
- Storage disconnected
- Memory usage > 90%
- Unable to serve requests
**Example Response (Healthy)**:
```json
{
"status": "healthy",
"uptime": 3600,
"version": "0.1.0",
"connections": {
"stdio": 0,
"http": 12,
"total": 12
},
"sessions": {
"active": 8,
"total": 15
},
"storage": {
"connected": true,
"responseTime": 2.5
},
"memory": {
"used": 45.2,
"total": 8192,
"percentage": 0.0055
},
"timestamp": 1712486460000
}
```
**Example Response (Degraded)**:
```json
{
"status": "degraded",
"uptime": 7200,
"version": "0.1.0",
"connections": {
"stdio": 0,
"http": 45,
"total": 45
},
"sessions": {
"active": 42,
"total": 50
},
"storage": {
"connected": true,
"responseTime": 150
},
"memory": {
"used": 6553.6,
"total": 8192,
"percentage": 0.8
},
"timestamp": 1712490060000
}
```
**Example Response (Unhealthy)**:
```json
{
"status": "unhealthy",
"uptime": 10800,
"version": "0.1.0",
"connections": {
"stdio": 0,
"http": 0,
"total": 0
},
"sessions": {
"active": 0,
"total": 0
},
"storage": {
"connected": false,
"responseTime": null
},
"memory": {
"used": 120,
"total": 8192,
"percentage": 0.015
},
"timestamp": 1712493660000
}
```
**cURL Example**:
```bash
curl -i http://localhost:8080/health
```
**Load Balancer Integration** (Nginx):
```nginx
upstream mcp_backend {
server mcp-server:3000;
health_check interval=10s uri=/health;
}
```
**Prometheus Monitoring**:
```yaml
scrape_configs:
- job_name: 'mcp-server'
static_configs:
- targets: ['localhost:8080']
metrics_path: '/health'
scrape_interval: 30s
```
---
## Rate Limiting (HTTP Mode Only)
When running in HTTP/2 remote mode, all MCP tool endpoints are subject to rate limiting.
**Algorithm**: Sliding Window Counter (Hybrid)
**Default Limits**:
- 100 requests per minute per client IP address
- Window size: 60 seconds
- Burst tolerance: ~150 requests in worst-case window overlap
**Rate Limit Headers** (included in all HTTP responses):
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1712486520
```
**Rate Limit Exceeded Response** (429 Too Many Requests):
```json
{
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"limit": 100,
"current": 105,
"resetAt": "2026-04-07T10:02:00.000Z",
"retryAfter": 15
}
```
**HTTP Headers on 429**:
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712486520
Retry-After: 15
Content-Type: application/json
```
**Exemptions**:
- Health check endpoint (`/health`) is exempt from rate limiting
- CORS preflight requests (OPTIONS) are exempt
**Configuration**:
```bash
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=100
RATE_LIMIT_WINDOW_SECONDS=60
```
---
## CORS Headers (HTTP Mode Only)
When running in HTTP/2 remote mode, all endpoints support cross-origin requests.
**CORS Policy**: Permissive wildcard (`Access-Control-Allow-Origin: *`)
**Preflight Request** (OPTIONS):
```
OPTIONS /mcp HTTP/1.1
Origin: https://web-client.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, MCP-Session-ID
```
**Preflight Response**:
```
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, MCP-Session-ID, X-Requested-With
Access-Control-Max-Age: 86400
```
**Actual Request Headers** (added to all responses):
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: false
Access-Control-Expose-Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
```
**Security Note**: Wildcard CORS is appropriate for development/testing with mock data only. Deploy within private networks (VPN, firewalls) for security.
---
## Transport Mode Configuration
**Environment Variables**:
```bash
# Transport selection
TRANSPORT_MODE=stdio|http|both # Default: stdio
# HTTP/2 configuration (when http or both)
HTTP_PORT=3000 # Internal port (default: 3000)
HTTP_HOST=127.0.0.1 # Bind address (default: 127.0.0.1 for proxy)
# External access via reverse proxy
# Nginx listens on :8080 (HTTP/2 + TLS)
# Proxies to mcp-server:3000 (HTTP/1.1)
```
**Protocol Notes (Updated)**:
- stdio mode: JSON-RPC 2.0 over stdin/stdout (local only)
- http mode: JSON-RPC 2.0 over SSE via StreamableHTTPServerTransport
- HTTP/2: Provided by nginx reverse proxy (terminates HTTP/2, proxies HTTP/1.1)
- CORS: Enabled in http mode, wildcard policy
- Rate limiting: Enabled in http mode, IP-based tracking
- Health checks: Available in http mode at `/health`

View File

@@ -392,3 +392,317 @@ No secondary indexes required - all queries use primary keys (sessionId, pnr)
Proceed to contract definition (Phase 1 continued): Proceed to contract definition (Phase 1 continued):
- Define MCP tool schemas in `/contracts/mcp-tools.md` - Define MCP tool schemas in `/contracts/mcp-tools.md`
- Create quickstart guide with example usage - Create quickstart guide with example usage
---
## Remote Access Entities (Added 2026-04-07)
The following entities support remote MCP access over HTTP/2 with rate limiting, health monitoring, and global PNR retrieval.
### 8. Remote Connection
Represents an active remote client connection over HTTP/2.
**Fields**:
```typescript
{
connectionId: string; // Unique connection identifier (UUID)
sessionId: string; // Associated MCP session ID
remoteIP: string; // Client IP address (for rate limiting)
connectedAt: number; // Unix timestamp (ms)
lastActivityAt: number; // Unix timestamp (ms)
transportType: string; // 'http' | 'stdio'
userAgent: string; // Client user agent
requestCount: number; // Total requests made in this connection
}
```
**Storage**: Valkey hash at key `connection:{connectionId}`
**Validation**:
- `remoteIP` must be valid IPv4 or IPv6 format
- `transportType` must be 'http' or 'stdio'
- `connectedAt` must be <= `lastActivityAt`
**State Transitions**:
```
[Connected] → [Active] → [Idle] → [Disconnected]
[Expired]
```
**Lifecycle**:
- Created on initial HTTP request with new session
- Updated on every request (lastActivityAt, requestCount)
- Auto-expires after SESSION_TTL_HOURS of inactivity
- Explicitly deleted on graceful disconnect
---
### 9. Rate Limit Record
Tracks request rates per client IP address for abuse prevention.
**Fields**:
```typescript
{
clientIP: string; // Client IP address (key component)
currentWindow: number; // Current time window (Unix timestamp / window_seconds)
currentCount: number; // Request count in current window
previousCount: number; // Request count in previous window
limit: number; // Max requests allowed per window
resetAt: number; // Next window reset timestamp
}
```
**Storage**:
- Current window: Valkey integer at key `ratelimit:{ip}:{currentWindow}`
- Previous window: Valkey integer at key `ratelimit:{ip}:{previousWindow}`
**Validation**:
- `clientIP` must be valid IP address
- `currentCount`, `previousCount` must be non-negative integers
- `limit` must be positive integer (default: 100)
- `resetAt` must be in the future
**Algorithm**: Sliding Window Counter (Hybrid)
```javascript
estimatedCount = (previousCount * previousWeight) + currentCount
where previousWeight = 1 - (elapsedInWindow / windowSeconds)
```
**Lifecycle**:
- Counter incremented on each request: `INCR ratelimit:{ip}:{window}`
- TTL set to 2× window duration (keeps previous window accessible)
- Auto-expires after TTL (no manual cleanup needed)
- Resets at window boundary (new window key)
**Error Response** (when limit exceeded):
```typescript
{
error: "Rate limit exceeded",
code: "RATE_LIMIT_EXCEEDED",
limit: 100,
current: 105,
resetAt: "2026-04-07T10:01:00.000Z",
retryAfter: 15 // seconds
}
```
---
### 10. Health Status
Represents server operational status for monitoring and health checks.
**Fields**:
```typescript
{
status: string; // 'healthy' | 'degraded' | 'unhealthy'
uptime: number; // Seconds since server start
version: string; // Server version
connections: {
stdio: number; // Active stdio connections (0 or 1 typically)
http: number; // Active HTTP connections
total: number; // Total active connections
},
sessions: {
active: number; // Active sessions (sessions with recent activity)
total: number; // Total sessions in Valkey
},
storage: {
connected: boolean; // Valkey connection status
responseTime: number; // Valkey ping response time (ms)
},
memory: {
used: number; // Process memory used (MB)
total: number; // Total system memory (MB)
percentage: number; // Memory usage percentage
},
timestamp: number; // Health check timestamp (Unix ms)
}
```
**Endpoint**: `GET /health` (unauthenticated, exempt from rate limiting)
**Status Determination**:
- **healthy**: All systems operational, storage connected, memory < 80%
- **degraded**: Storage slow (ping > 100ms) or memory 80-90%
- **unhealthy**: Storage disconnected or memory > 90%
**Response Codes**:
- 200 OK: status = 'healthy'
- 200 OK: status = 'degraded' (still serving requests)
- 503 Service Unavailable: status = 'unhealthy'
**Example Response**:
```json
{
"status": "healthy",
"uptime": 3600,
"version": "0.1.0",
"connections": {
"stdio": 0,
"http": 12,
"total": 12
},
"sessions": {
"active": 8,
"total": 15
},
"storage": {
"connected": true,
"responseTime": 2
},
"memory": {
"used": 45,
"total": 8192,
"percentage": 0.55
},
"timestamp": 1712486460000
}
```
**Use Cases**:
- Load balancer health checks
- Monitoring system integration (Prometheus, Datadog)
- Deployment validation (verify server started successfully)
- Debugging (check connection counts, memory usage)
---
## Updated Storage Schema (Remote Mode)
### Global PNR Storage (Session-Independent)
**Key Change**: PNRs now stored globally with TTL, not scoped to sessions.
```
pnr:{pnr} # Global PNR storage
→ {
pnr: string,
status: 'confirmed' | 'cancelled',
createdAt: ISO8601,
expiresAt: ISO8601,
creatingSessionId: string, # For logging only, not access control
segments: [...],
passengers: [...],
totalPrice: number
}
```
**TTL**: Configurable via `PNR_TTL_HOURS` (default 1 hour)
**Access**: Any session can retrieve any PNR (global retrieval)
**Expiration**: PNR auto-deleted by Valkey after TTL expires
---
### Session PNR Reference
Sessions track which PNRs they created (for `listBookings` tool):
```
session:{sessionId}:pnrs # Set of PNR codes created in this session
→ Set<string> # e.g., ["TEST-ABC123", "TEST-DEF456"]
```
**Purpose**: Enable `listBookings` to return session-created PNRs
**Lifecycle**: Deleted when session expires (PNRs persist independently)
---
### Rate Limit Keys
```
ratelimit:{ip}:{window} # Request count for IP in time window
→ integer # e.g., 45 (requests made)
TTL: windowSeconds * 2 # Keep previous window accessible
```
**Example**:
```
ratelimit:192.168.1.1:287456 # Current window (e.g., minute 287456)
→ 45
ratelimit:192.168.1.1:287455 # Previous window
→ 92
```
---
### Connection Tracking
```
connection:{connectionId} # Remote connection metadata
→ { connectionId, sessionId, remoteIP, connectedAt, ... }
TTL: SESSION_TTL_HOURS
```
**Purpose**: Track active HTTP connections for health monitoring
**Cleanup**: Auto-expires with session TTL
---
## Updated Validation Rules (Remote Mode)
### Additional Validations
1. **IP Address Validation**:
- Must be valid IPv4 (e.g., `192.168.1.1`) or IPv6 format
- Used for rate limiting and logging
- Extracted from `X-Forwarded-For` or `X-Real-IP` headers (trusted proxy)
2. **Session ID Format** (HTTP mode):
- Must be valid UUID v4
- Sent via `MCP-Session-ID` header
- Generated by transport if not provided
3. **Rate Limit Headers** (HTTP mode):
- `X-RateLimit-Limit`: Integer > 0
- `X-RateLimit-Remaining`: Integer >= 0
- `X-RateLimit-Reset`: Unix timestamp
- `Retry-After`: Seconds (when limit exceeded)
4. **CORS Headers** (HTTP mode):
- `Origin`: Any (wildcard policy)
- `Access-Control-Allow-Origin`: Must be `*`
- Preflight requests must use OPTIONS method
---
## Performance Considerations (Remote Mode)
### Additional Overhead
- **Rate Limiting**: +3 Valkey ops per request (~1-2ms overhead)
- **Connection Tracking**: +1 Valkey write per request (~0.5ms overhead)
- **CORS Preflight**: OPTIONS requests handled immediately (no tool execution)
- **Health Checks**: Separate fast path (no session/rate limit checks)
### Expected Performance (Remote)
- **Search Operations**: <2s (requirement: SC-003)
- **Booking Operations**: <500ms (includes rate limit check)
- **Retrieval Operations**: <200ms (global PNR lookup)
- **Health Check**: <100ms (Valkey ping only)
- **Concurrent Remote Sessions**: 50+ (requirement: SC-012)
### Optimization Strategies
1. **Rate Limit Caching**: Cache IP counters in-memory for 1 second (reduce Valkey ops)
2. **Connection Pooling**: Reuse HTTP/2 connections (handled by Nginx)
3. **Health Check Caching**: Cache health status for 5 seconds
4. **CORS Preflight Caching**: 24-hour `Access-Control-Max-Age`
---
## Next Steps
Data model complete with remote access entities. Proceed to:
1. ✅ Update contracts/ with health endpoint schema
2. ✅ Update quickstart.md with remote access setup
3. ✅ Run agent context update script

View File

@@ -1,192 +1,256 @@
# Implementation Plan: Mock GDS MCP Server # Implementation Plan: Mock GDS MCP Server
**Branch**: `001-mock-gds-server` | **Date**: 2026-04-07 | **Spec**: [spec.md](./spec.md) **Branch**: `001-mock-gds-server` | **Date**: 2026-04-11 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-mock-gds-server/spec.md` **Input**: Feature specification from `/specs/001-mock-gds-server/spec.md`
## Summary ## Summary
Build a Mock MCP server that simulates Global Distribution System (GDS) functionality for testing and demonstration. The server exposes GDS operations (search, book, retrieve, cancel) as MCP tools, maintains stateful session management for multi-step workflows, uses Valkey for persistence, and packages as a Docker container for easy deployment. Build a Mock Global Distribution System (GDS) server exposing flight, hotel, and car rental booking operations as Model Context Protocol (MCP) tools via HTTP transport. The server provides realistic mock data for testing travel applications without connecting to real GDS systems, with session-based state management and multi-service booking capabilities.
## Technical Context ## Technical Context
**Language/Version**: Node.js 20 LTS (current stable) **Language/Version**: Node.js 20 LTS with TypeScript 6.0.2
**Primary Dependencies**: Minimal libraries - MCP SDK (@modelcontextprotocol/sdk), Valkey client (ioredis), Docker buildx for multi-platform builds **Primary Dependencies**: @modelcontextprotocol/sdk ^1.0.4 (MCP protocol), @modelcontextprotocol/server ^2.0.0-alpha.2 (McpServer), @modelcontextprotocol/express ^2.0.0-alpha.2 (HTTP transport), ioredis ^5.4.1 (Valkey client), Express ^5.2.1, pino ^9.5.0 (structured logging)
**Storage**: Valkey 8.0+ (Redis-compatible in-memory store with persistence) **Storage**: Valkey 8.0+ (Redis-compatible in-memory store with persistence for PNRs and sessions)
**Testing**: Node.js native test runner (node:test) with minimal external dependencies **Testing**: Node.js built-in test runner or Vitest (TBD in Phase 0)
**Target Platform**: Docker containers (Linux amd64/arm64), deployable via docker-compose **Target Platform**: Linux/macOS servers, Docker containers (multi-platform: linux/amd64, linux/arm64)
**Project Type**: MCP server (daemon/service) **Project Type**: MCP server (HTTP-based tool provider for AI agents and MCP clients)
**Performance Goals**: <2s search response time, 50+ concurrent sessions, <500ms booking operations **Performance Goals**: Support 100+ concurrent sessions, <200ms p95 response time for search operations, <500ms for booking operations
**Constraints**: Minimal dependencies (avoid framework bloat), stateless server design (state in Valkey), zero external API calls **Constraints**: Stateless HTTP transport design (no server-side session affinity required), connection pooling for Valkey, structured logging for observability, all operations deterministic for given inputs
**Scale/Scope**: Single-server deployment, 1000+ bookings/session, 100+ concurrent MCP connections **Scale/Scope**: 11 MCP tools (search/book/cancel for flights/hotels/cars, plus session management), ~5000 lines of TypeScript, realistic mock data (100+ airports, 20+ airlines, 50+ hotels, 15+ car companies)
## Constitution Check ## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
### Principle I: MCP Protocol Compliance (NON-NEGOTIABLE) ### I. MCP Protocol Compliance
- **Conformance**: Using official @modelcontextprotocol/sdk ensures protocol compliance - **Status**: COMPLIANT
- **JSON-RPC 2.0**: SDK handles message format automatically - **Evidence**: Using official @modelcontextprotocol/server package with McpServer class, all tools registered via standard API, StreamableHTTPServerTransport for MCP HTTP specification compliance
- **Capability Declaration**: Server will declare tools during initialization handshake - **Validation**: McpServer handles JSON-RPC 2.0 message format automatically, capabilities declared in server initialization, error responses use MCP error structures
-**Error Codes**: SDK provides standard MCP error structures
**Status**: PASS - Using official SDK guarantees protocol compliance ### II. Mock Data Realism ✅
- **Status**: COMPLIANT
- **Evidence**: Mock data uses real IATA airport codes (JFK, LAX, etc.), valid airline codes (AA, DL, UA), realistic pricing ($200-$800 flights, $80-$400 hotels), flight times respect geography and time zones
- **Implementation**: data/ directory contains realistic airlines, airports, flights, hotels, cars with internally consistent relationships
### Principle II: Mock Data Realism ### III. No Real Transactions (Safety Guarantee) ✅
- **GDS Structures**: Will implement SABRE/Amadeus/Galileo-style response formats - **Status**: COMPLIANT
- **Valid IATA/ICAO Codes**: Mock data will use real airport (JFK, LAX) and airline (AA, DL, UA) codes - **Evidence**: Server is pure mock implementation with no external GDS connections, PNRs generated locally with clear test format, no production API keys accepted
- **Realistic Pricing**: Price ranges match market expectations ($200-$800 domestic flights) - **Safety Markers**: Documentation includes "MOCK" and "FOR TESTING ONLY" disclaimers, no real booking system integration
-**Real-World Constraints**: Flight times, connection logic, geography respected
-**Edge Cases**: Will include sold-out flights, price variations, booking errors
**Status**: PASS - Spec requirements align with realism principle ### IV. Tool-Based Architecture ✅
- **Status**: COMPLIANT
- **Evidence**: 11 MCP tools exposed (searchFlights, bookFlight, searchHotels, bookHotel, searchCars, bookCar, retrieveBooking, cancelBooking, listBookings, getSessionInfo, clearSession)
- **Implementation**: Each tool registered with description and inputSchema, tools are independently callable, responses deterministic for same inputs
### Principle III: No Real Transactions (Safety Guarantee) ### V. Stateful Session Management ✅
- **Zero External Connections**: No real GDS APIs called (all data from Valkey/memory) - **Status**: COMPLIANT
- **Simulated Operations**: All bookings are mock data only - **Evidence**: Session manager tracks state per session ID, Valkey storage isolates bookings by session, PNRs stored with configurable TTL (default 1 hour)
- **TEST Markers**: PNRs will have "TEST-" prefix (e.g., TEST-ABC123) - **Implementation**: SessionManager class provides create/get/update/delete operations, state transitions explicit (search → book → confirm)
-**Documentation**: README will include prominent "FOR TESTING AND DEMO PURPOSES ONLY" disclaimer
-**Configuration Safety**: No production API key acceptance (configuration validates against production patterns)
**Status**: PASS - Safety guarantees built into design ### VI. Observable and Debuggable ✅
- **Status**: COMPLIANT
- **Evidence**: Structured logging via pino with configurable levels (INFO, DEBUG, ERROR), all tool calls logged with parameters and results, error messages include actionable guidance
- **Implementation**: Logger middleware logs all MCP requests, tool execution tracked with duration metrics, health endpoint for server status
### Principle IV: Tool-Based Architecture **Gate Result**: ✅ PASS - All constitutional requirements met
-**MCP Tools**: Each operation exposed as tool (searchFlights, bookFlight, retrieveBooking, cancelBooking, searchHotels, bookHotel, searchCars, bookCar)
-**Self-Documenting**: JSON schemas with descriptions and validation
-**Independent Tools**: Each tool callable without dependencies
-**Deterministic**: Same inputs produce same outputs (controlled randomness for demo variety)
-**Resources**: Will expose resources for session state inspection
**Status**: PASS - Tool-based design matches MCP patterns
### Principle V: Stateful Session Management
-**Session State**: Valkey stores session data (searches, bookings, PNRs)
-**Isolation**: Session ID scopes all data (no cross-session leakage)
-**Explicit Transitions**: search → price → book → confirm flow tracked
-**Cleanup**: TTL on session keys for automatic expiry
-**Optional Persistence**: Valkey persistence configurable (RDB/AOF)
**Status**: PASS - Valkey provides robust session management
### Principle VI: Observable and Debuggable
-**Structured Logging**: Pino logger with operation type, parameters, results
-**Error Classification**: Client errors (4xx), server errors (5xx), success (2xx)
-**Configurable Verbosity**: LOG_LEVEL env var (silent, error, warn, info, debug, trace)
-**Actionable Errors**: Clear messages ("Invalid airport code 'XYZ': must be 3-letter IATA")
-**Inspection**: MCP resource endpoints for session list, booking history
**Status**: PASS - Logging and observability built in
### Overall Gate Status: ✅ PASS
All constitution principles satisfied. Proceed with Phase 0 research.
## Project Structure ## Project Structure
### Documentation (this feature) ### Documentation (this feature)
```text ```text
specs/[###-feature]/ specs/001-mock-gds-server/
├── plan.md # This file (/speckit.plan command output) ├── plan.md # This file (updated with TypeScript details)
├── research.md # Phase 0 output (/speckit.plan command) ├── research.md # Technology decisions and best practices
├── data-model.md # Phase 1 output (/speckit.plan command) ├── data-model.md # Entity definitions and state models
├── quickstart.md # Phase 1 output (/speckit.plan command) ├── quickstart.md # Getting started guide
├── contracts/ # Phase 1 output (/speckit.plan command) ├── contracts/ # MCP tool schemas and API contracts
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) │ └── mcp-tools.md # Tool definitions with input/output schemas
├── spec.md # Feature specification
└── tasks.md # Implementation task breakdown
``` ```
### Source Code (repository root) ### Source Code (repository root)
```text ```text
src/ src/ # TypeScript source files
├── index.js # MCP server entry point ├── index.ts # Main entry point, tool registration
├── server.js # MCP server initialization ├── server.ts # GDSMockServer class (McpServer wrapper)
├── tools/ # MCP tool handlers
│ ├── flights.js # searchFlights, bookFlight
│ ├── hotels.js # searchHotels, bookHotel
│ ├── cars.js # searchCars, bookCar
│ └── bookings.js # retrieveBooking, cancelBooking
├── data/ # Mock data generators ├── data/ # Mock data generators
│ ├── airports.js # IATA airport codes and data │ ├── airlines.ts # Airline database and lookups
│ ├── airlines.js # Airline codes and metadata │ ├── airports.ts # Airport codes and geography
│ ├── hotels.js # Hotel chains and properties │ ├── flights.ts # Flight generation logic
│ ├── cars.js # Car rental companies and types │ ├── hotels.ts # Hotel data and pricing
── pnr.js # PNR generation utilities ── cars.ts # Car rental options
├── session/ # Session management │ └── pnr.ts # PNR generation utilities
│ ├── manager.js # Session lifecycle ├── session/ # Session and storage management
── storage.js # Valkey client wrapper ── manager.ts # SessionManager class
├── validation/ # Input validation │ ├── storage.ts # ValkeyStorage (Redis client wrapper)
── schemas.js # JSON schemas for tools ── valkey-event-store.ts # Event store for MCP resumability
│ └── validators.js # Validation logic ├── tools/ # MCP tool implementations
└── utils/ # Shared utilities │ ├── flights.ts # searchFlights, bookFlight
├── logger.js # Pino logger setup ├── hotels.ts # searchHotels, bookHotel
── errors.js # Error handling ── cars.ts # searchCars, bookCar
│ ├── bookings.ts # retrieveBooking, cancelBooking, listBookings
│ └── session.ts # getSessionInfo, clearSession
├── transports/ # HTTP transport layer
│ ├── http-server.ts # StreamableHTTPServerTransport setup
│ └── factory.ts # Transport factory
├── middleware/ # Express middleware
│ ├── cors.ts # CORS policy (allow all origins)
│ ├── rate-limit.ts # IP-based rate limiting
│ ├── logger.ts # Request logging
│ ├── protocol-version.ts # MCP-Protocol-Version validation
│ └── message-normalization.ts # Request normalization
├── utils/ # Shared utilities
│ ├── errors.ts # Error classes (MCPError, ValidationError, etc.)
│ └── logger.ts # Pino logger configuration
└── validation/ # Input validation
└── validators.ts # Validation helpers
tests/ dist/ # Compiled JavaScript output (build artifacts)
├── integration/ # End-to-end MCP workflows ├── index.js
│ ├── flight-booking.test.js ├── server.js
│ ├── multi-service.test.js └── [mirrors src/ structure]
│ └── concurrent-sessions.test.js
├── unit/ # Tool and data tests
│ ├── tools/
│ ├── data/
│ └── session/
└── fixtures/ # Test data
docker/ tests/ # Test files (TBD)
├── Dockerfile # Multi-stage build ├── unit/
── docker-bake.hcl # Buildx bake configuration ── integration/
└── contract/
docker-compose.yaml # Local dev/test environment docker/ # Docker configuration
├── Dockerfile # Multi-platform build
└── entrypoint.sh # Container startup script
tsconfig.json # TypeScript compiler configuration
package.json # Dependencies and scripts package.json # Dependencies and scripts
.dockerignore # Build exclusions
README.md # Setup and usage
``` ```
**Structure Decision**: Single project layout selected. The Mock GDS MCP server is a standalone daemon with embedded mock data. All source code in `src/`, organized by functional concerns (tools, data, session, validation, utils). Docker configuration in `docker/` directory with multi-stage Dockerfile and buildx bake configuration. Tests organized by type (unit, integration) with shared fixtures. **Structure Decision**: Single Node.js/TypeScript project with clear separation of concerns. Source in `src/` compiled to `dist/` via TypeScript compiler. MCP tools in `tools/`, data generators in `data/`, session management in `session/`, HTTP transport in `transports/`. Middleware chain handles CORS, rate limiting, protocol validation. Valkey for persistent storage. Docker support for containerized deployment.
## Phase 0: Research & Technology Decisions (COMPLETED)
**Status**: ✅ Complete
**Output**: `research.md`
**Completion Date**: 2026-04-07
All technical unknowns resolved:
- MCP SDK selection (@modelcontextprotocol/sdk)
- Valkey client library (ioredis)
- Minimal dependencies strategy (Pino for logging, native test runner)
- Docker buildx bake configuration
- Mock data architecture (embedded, deterministic generation)
- PNR generation strategy (TEST- prefix with base32)
- Configuration management (environment variables)
- Testing strategy (3-tier: unit, integration, contract)
## Phase 1: Design & Contracts (COMPLETED)
**Status**: ✅ Complete
**Output**: `data-model.md`, `contracts/mcp-tools.md`, `quickstart.md`
**Completion Date**: 2026-04-07
Design artifacts created:
- **Data Model**: 8 core entities (Session, Passenger, FlightSegment, HotelReservation, CarRental, PNR, SearchQuery, MockDataRecord)
- **MCP Tool Contracts**: 8 tools defined with JSON schemas (searchFlights, bookFlight, searchHotels, bookHotel, searchCars, bookCar, retrieveBooking, cancelBooking, listBookings)
- **Quick Start Guide**: Complete usage examples for all workflows
- **Agent Context**: Updated Copilot context with Node.js, MCP SDK, Valkey stack
### Post-Design Constitution Re-Check
All principles remain satisfied after detailed design:
-**Principle I (MCP Protocol)**: Tool schemas follow JSON-RPC 2.0 specification
-**Principle II (Mock Data Realism)**: Data model includes valid IATA codes, realistic pricing tiers
-**Principle III (No Real Transactions)**: PNR format enforces TEST- prefix, no external APIs
-**Principle IV (Tool Architecture)**: 8 independent tools with clear schemas
-**Principle V (Session Management)**: Valkey key naming and TTL strategy defined
-**Principle VI (Observability)**: Error codes, logging strategy, inspection resources specified
**Final Gate Status**: ✅ PASS - Ready for task generation
## Complexity Tracking ## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified** **No constitution violations** - All core principles satisfied without requiring exceptions or workarounds.
## Phase 0: Research & Technology Decisions
### Technology Stack Rationale
#### Node.js 20 LTS + TypeScript
**Decision**: Use Node.js 20 LTS with TypeScript 6.0.2 for type safety and modern JavaScript features
**Rationale**:
- MCP SDK officially supports Node.js with comprehensive TypeScript typings
- Strong ecosystem for HTTP servers and real-time streaming (SSE)
- TypeScript provides compile-time type checking reducing runtime errors
- ES2022 features (top-level await, private fields) improve code quality
- Active LTS support ensures stability and security updates
**Alternatives Considered**:
- Python with FastAPI: Good MCP support but weaker typing story, slower cold starts
- Go: Excellent performance but less mature MCP ecosystem
- Deno: Modern runtime but MCP SDK compatibility uncertain
#### MCP SDK Architecture
**Decision**: Use high-level `McpServer` from `@modelcontextprotocol/server` package
**Rationale**:
- Simplified tool registration API: `registerTool(name, config, callback)`
- Automatic JSON-RPC 2.0 message handling
- Built-in error handling and protocol validation
- Recommended approach per MCP SDK examples
- Reduces boilerplate compared to low-level `Server` class
**Implementation**:
```typescript
const server = new McpServer(
{ name: 'gds-mock-mcp', version: '0.1.0' },
{ capabilities: { tools: {}, logging: {} } }
);
server.registerTool('searchFlights', { description, inputSchema }, handler);
```
#### Valkey for State Storage
**Decision**: Valkey 8.0+ (Redis-compatible) for session and booking storage
**Rationale**:
- In-memory performance (<1ms read/write) meets latency requirements
- TTL support for automatic PNR expiration (1 hour default)
- Connection pooling via ioredis client
- Redis protocol compatibility enables ecosystem tooling
- Optional persistence (RDB/AOF) for development convenience
**Alternatives Considered**:
- In-memory only: Fast but state lost on restart, not suitable for demos
- PostgreSQL: Overkill for mock data, adds deployment complexity
- SQLite: File-based but lacks TTL and concurrent access patterns
#### HTTP Transport Strategy
**Decision**: StreamableHTTPServerTransport in stateless mode with Express 5
**Rationale**:
- MCP HTTP specification (2025-11-25) mandates HTTP/1.1 + SSE
- Stateless mode allows horizontal scaling (no session affinity)
- Express 5 provides modern middleware architecture
- @modelcontextprotocol/express package simplifies integration
- Health endpoint (`/health`) for monitoring without MCP overhead
**Key Pattern**:
```typescript
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // Stateless - clients manage sessions
});
await transport.handleRequest(req, res); // Processes MCP messages
```
#### Logging Strategy
**Decision**: Structured JSON logging via pino with configurable levels
**Rationale**:
- Structured logs enable automated analysis and monitoring
- Pino is fastest Node.js logger (benchmarks show 10x faster than Winston)
- Log levels (DEBUG, INFO, ERROR) support development and production
- Tool execution tracking with duration metrics
- Correlation IDs via session tracking
**Output Example**:
```json
{
"level": "INFO",
"time": "2026-04-11T18:37:43.129Z",
"service": "gds-mock-mcp",
"tool": "searchFlights",
"sessionId": "abc123",
"duration": 45,
"msg": "Tool execution completed"
}
```
### Testing Approach
**Decision**: Node.js built-in test runner with contract tests for MCP compliance
**Rationale**:
- Node.js 20 includes native test runner (no external dependencies)
- Contract tests validate MCP tool schemas and responses
- Integration tests cover multi-step workflows (search → book → cancel)
- Unit tests for data generators and validation logic
**Test Categories**:
1. **Contract Tests**: Verify MCP tool schemas match declared types
2. **Integration Tests**: End-to-end workflows with Valkey
3. **Unit Tests**: Data generation, validation, PNR logic
### Development Tooling
**Decision**:
- `tsx` for development mode with hot reload
- `tsc` for production builds
- ESLint + Prettier for code quality
- Docker multi-platform builds (linux/amd64, linux/arm64)
**Scripts**:
```json
{
"dev": "tsx watch src/index.ts --verbose",
"build": "tsc",
"start": "node dist/index.js",
"test": "node --test tests/**/*.test.js"
}
```
| Violation | Why Needed | Simpler Alternative Rejected Because | | Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------| |-----------|------------|-------------------------------------|

View File

@@ -506,3 +506,571 @@ Verify you're retrieving from the same session that created the booking.
- Review data model in `/data-model.md` - Review data model in `/data-model.md`
- Check technical research in `/research.md` - Check technical research in `/research.md`
- See implementation tasks in `/tasks.md` (after running `/speckit.tasks`) - See implementation tasks in `/tasks.md` (after running `/speckit.tasks`)
---
## Remote Access Setup (HTTP/2 Mode)
The mock GDS server can be accessed remotely over HTTP/2, enabling distributed teams and web-based MCP clients.
### Remote Mode Configuration
**Update `.env` for remote access**:
```bash
# Transport Mode
TRANSPORT_MODE=http # Enable HTTP transport (or 'both' for stdio + http)
# HTTP Server
HTTP_PORT=3000 # Internal HTTP port (proxied by nginx)
HTTP_HOST=127.0.0.1 # Bind to localhost (nginx only)
# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=100 # 100 requests per minute per IP
RATE_LIMIT_WINDOW_SECONDS=60
# PNR/Session Timeouts
PNR_TTL_HOURS=1 # Global PNR retrieval window
SESSION_TTL_HOURS=24 # Session expiration
# CORS
CORS_ENABLED=true
CORS_ORIGINS=* # Wildcard for development
CORS_MAX_AGE=86400
```
### Docker Compose Setup (Remote Mode)
**docker-compose.yml** (with nginx reverse proxy):
```yaml
version: '3.8'
services:
valkey:
image: valkey/valkey:8.0-alpine
ports:
- "6379:6379"
volumes:
- valkey-data:/data
command: valkey-server --appendonly yes
mcp-server:
build: .
environment:
TRANSPORT_MODE: http
HTTP_PORT: 3000
HTTP_HOST: 0.0.0.0
VALKEY_HOST: valkey
VALKEY_PORT: 6379
RATE_LIMIT_ENABLED: "true"
RATE_LIMIT_PER_MINUTE: 100
PNR_TTL_HOURS: 1
LOG_LEVEL: info
depends_on:
- valkey
ports:
- "3000:3000" # For development without nginx
nginx:
image: nginx:alpine
ports:
- "8080:8080" # External HTTP/2 port
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
- mcp-server
volumes:
valkey-data:
```
### Nginx Configuration
**nginx/nginx.conf**:
```nginx
events {
worker_connections 1024;
}
http {
upstream mcp_backend {
server mcp-server:3000;
}
server {
listen 8080 ssl http2;
server_name localhost;
# TLS Configuration
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# MCP Endpoints
location /mcp {
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
# Headers for rate limiting and logging
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
# SSE Support (for MCP streaming)
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 3600s;
# CORS (optional - can be handled by app)
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Expose-Headers 'X-RateLimit-Limit, X-RateLimit-Remaining' always;
}
# Health Check
location /health {
proxy_pass http://mcp_backend/health;
proxy_http_version 1.1;
access_log off; # Don't log health checks
}
}
}
```
### Generate Self-Signed Certificate (Development)
```bash
# Create certs directory
mkdir -p nginx/certs
# Generate self-signed certificate (valid 365 days)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout nginx/certs/key.pem \
-out nginx/certs/cert.pem \
-subj "/C=US/ST=Dev/L=Local/O=MockGDS/CN=localhost"
# Set permissions
chmod 644 nginx/certs/cert.pem
chmod 600 nginx/certs/key.pem
```
### Start Remote Server
```bash
# Start all services
docker-compose up -d
# Check status
docker-compose ps
# View logs
docker-compose logs -f mcp-server nginx
# Test health endpoint
curl -k https://localhost:8080/health
# Test CORS
curl -i -H "Origin: https://example.com" -X OPTIONS https://localhost:8080/mcp
```
---
## Connecting Remote MCP Clients
### Web-Based Client (Browser)
```javascript
// Example: Connect from browser-based MCP client
const mcpClient = new MCPClient({
url: 'https://localhost:8080/mcp',
transport: 'sse', // Server-sent events
sessionId: generateSessionId() // Or let server generate
});
await mcpClient.connect();
// Call tools
const flights = await mcpClient.callTool('searchFlights', {
origin: 'JFK',
destination: 'LAX',
departureDate: '2026-06-15',
passengers: { adults: 1 },
cabin: 'economy'
});
console.log(`Found ${flights.resultCount} flights`);
```
### cURL Examples
**Search flights**:
```bash
curl -k -X POST https://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-ID: $(uuidgen)" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "searchFlights",
"arguments": {
"origin": "JFK",
"destination": "LAX",
"departureDate": "2026-06-15",
"passengers": { "adults": 1 },
"cabin": "economy"
}
},
"id": 1
}'
```
**Check health**:
```bash
curl -k https://localhost:8080/health | jq
```
**Check rate limits**:
```bash
# Make multiple requests and check headers
for i in {1..5}; do
curl -k -i https://localhost:8080/health 2>&1 | grep -i ratelimit
done
```
---
## Rate Limiting Examples
### Normal Usage
```bash
# First request
curl -i https://localhost:8080/health
# Returns:
# X-RateLimit-Limit: 100
# X-RateLimit-Remaining: 99
# X-RateLimit-Reset: 1712486520
```
### Rate Limit Exceeded
```bash
# Make 101 requests in under 60 seconds
for i in {1..101}; do
curl -s https://localhost:8080/health > /dev/null
done
# 101st request returns:
# HTTP/1.1 429 Too Many Requests
# X-RateLimit-Limit: 100
# X-RateLimit-Remaining: 0
# X-RateLimit-Reset: 1712486520
# Retry-After: 15
#
# {
# "error": "Rate limit exceeded",
# "code": "RATE_LIMIT_EXCEEDED",
# "limit": 100,
# "current": 101,
# "resetAt": "2026-04-07T10:02:00.000Z",
# "retryAfter": 15
# }
```
### Waiting for Reset
```bash
# Wait until reset time
sleep 15
# Try again (should work)
curl https://localhost:8080/health
# Returns 200 OK with fresh rate limit
```
---
## Global PNR Retrieval Demo
Remote mode enables cross-session PNR retrieval for testing flexibility.
### Session 1: Create Booking
```bash
SESSION1=$(uuidgen)
curl -k -X POST https://localhost:8080/mcp \
-H "MCP-Session-ID: $SESSION1" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "bookFlight",
"arguments": {
"flightId": "flight_1",
"passengers": [{
"type": "adult",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com"
}]
}
},
"id": 1
}'
# Returns: { "pnr": "TEST-ABC123", ... }
```
### Session 2: Retrieve Same PNR
```bash
SESSION2=$(uuidgen)
curl -k -X POST https://localhost:8080/mcp \
-H "MCP-Session-ID: $SESSION2" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "retrieveBooking",
"arguments": {
"pnr": "TEST-ABC123"
}
},
"id": 1
}'
# Returns: Full PNR details (cross-session retrieval works!)
```
### After 1 Hour: PNR Expired
```bash
# Wait 1 hour (or set PNR_TTL_HOURS=0.01 for 36 seconds)
sleep 3660
curl -k -X POST https://localhost:8080/mcp \
-H "MCP-Session-ID: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "retrieveBooking",
"arguments": { "pnr": "TEST-ABC123" }
},
"id": 1
}'
# Returns:
# {
# "error": {
# "code": -32001,
# "message": "PNR TEST-ABC123 not found or expired"
# }
# }
```
---
## Security Recommendations
### Development Environment
**Safe for local development**:
- Use on localhost or private networks
- Self-signed certificates acceptable
- Wildcard CORS appropriate for testing
### Shared Development Server
⚠️ **Use network-level security**:
```bash
# Option 1: VPN Access
# Deploy server within VPN, require VPN connection
# Option 2: Firewall Rules
# Allow only specific IP ranges
iptables -A INPUT -p tcp --dport 8080 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 8080 -j DROP
# Option 3: Private Network (AWS/GCP)
# Deploy in private subnet, access via bastion host or VPN
```
### Production-Like Environment
**DO NOT deploy to public internet without authentication**:
- Mock server has no authentication (by design for v1)
- Wildcard CORS allows any origin
- Contains test data but still inappropriate for public exposure
**Future Enhancement**: Authentication layer for production deployments
---
## Monitoring & Observability
### Health Check Integration
**Kubernetes Liveness Probe**:
```yaml
livenessProbe:
httpGet:
path: /health
port: 8080
scheme: HTTPS
initialDelaySeconds: 30
periodSeconds: 10
```
**Prometheus Scraping**:
```yaml
scrape_configs:
- job_name: 'mcp-server'
scheme: https
tls_config:
insecure_skip_verify: true
static_configs:
- targets: ['localhost:8080']
metrics_path: '/health'
```
### Log Aggregation
**View structured logs**:
```bash
docker-compose logs -f mcp-server | jq
```
**Filter by session**:
```bash
docker-compose logs mcp-server | grep "sessionId:abc-123"
```
**Monitor rate limits**:
```bash
docker-compose logs mcp-server | grep "RATE_LIMIT_EXCEEDED"
```
---
## Troubleshooting (Remote Mode)
### Connection Refused
```bash
# Check if server is running
docker-compose ps
# Check if port is exposed
netstat -an | grep 8080
# Check nginx logs
docker-compose logs nginx
```
### CORS Errors in Browser
```javascript
// Console error: "Access-Control-Allow-Origin"
// Check CORS headers:
curl -i -H "Origin: https://example.com" https://localhost:8080/mcp
# Should include: Access-Control-Allow-Origin: *
```
### Rate Limit Too Restrictive
```bash
# Increase limit in .env
RATE_LIMIT_PER_MINUTE=1000
# Restart server
docker-compose restart mcp-server
```
### SSL Certificate Warnings
```bash
# Browser: "Your connection is not private"
# Expected with self-signed cert
# Production: Use Let's Encrypt
certbot certonly --standalone -d your-domain.com
# Update nginx.conf with real certificate paths
```
### PNR Not Found (Cross-Session)
```bash
# Verify PNR is within TTL window
curl https://localhost:8080/health | jq .uptime
# If uptime > 3600 and PNR_TTL_HOURS=1, PNR may have expired
# Check Valkey directly
docker-compose exec valkey valkey-cli
> KEYS pnr:*
> TTL pnr:TEST-ABC123
```
---
## Performance Testing (Remote Mode)
### Load Testing with `ab` (Apache Bench)
```bash
# Test health endpoint (10000 requests, 100 concurrent)
ab -n 10000 -c 100 -k https://localhost:8080/health
# Expected: <2s for 10k requests
```
### Rate Limit Testing
```bash
# Verify rate limiting kicks in
ab -n 200 -c 1 https://localhost:8080/health
# Should see ~100 successful, ~100 rate limited (429)
```
### Concurrent Sessions
```bash
# Simulate 50 concurrent sessions
for i in {1..50}; do
(
SESSION=$(uuidgen)
curl -k -X POST https://localhost:8080/mcp \
-H "MCP-Session-ID: $SESSION" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' &
)
done
wait
# Check health to see connection count
curl -k https://localhost:8080/health | jq .connections
```
---
## Next Steps (Remote Mode)
1. ✅ Set up Docker Compose with nginx reverse proxy
2. ✅ Generate TLS certificates for development
3. ✅ Configure environment variables for remote access
4. ✅ Test health endpoint and CORS
5. ✅ Verify rate limiting behavior
6. ✅ Test cross-session PNR retrieval
7. ✅ Monitor logs and health metrics
8. 🔄 Integrate with your web-based MCP client
9. 🔄 Deploy to shared development environment (with VPN/firewall)
10. 📋 Plan authentication layer for future production use

View File

@@ -297,3 +297,462 @@ Proceed to Phase 1: Design & Contracts
- Define MCP tool contracts in contracts/ - Define MCP tool contracts in contracts/
- Generate quickstart.md with usage examples - Generate quickstart.md with usage examples
- Update agent context with technology decisions - Update agent context with technology decisions
---
## Remote Access Research (Added 2026-04-07)
This section documents research findings for remote MCP access requirements based on clarifications received after initial planning.
### Decision 8: Streamable HTTP Transport Implementation (MCP Specification Compliant)
**Decision**: Use MCP SDK's `StreamableHTTPServerTransport` over HTTP/1.1 with Server-Sent Events (SSE)
**Question**: How to implement remote transport for MCP SDK per official specification?
**Investigation Findings**:
The MCP specification (2025-11-25) defines **Streamable HTTP** as the standard remote transport. The MCP SDK (@modelcontextprotocol/sdk v1.0.4) provides official transport implementations:
1. **StdioServerTransport** - stdio for local process communication
2. **StreamableHTTPServerTransport** - Remote HTTP/1.1 using SSE (spec-compliant)
3. **WebStandardStreamableHTTPServerTransport** - Platform-agnostic HTTP
4. **SSEServerTransport** - Deprecated legacy transport
**Key Finding**: MCP's **Streamable HTTP** transport uses HTTP/1.1 with Server-Sent Events (SSE), NOT HTTP/2. The specification requires:
- Single endpoint supporting POST, GET, and DELETE methods
- POST for client→server messages (returns SSE stream or 202)
- GET for server→client message stream (optional)
- DELETE for explicit session termination
- SSE for server→client streaming
- Session management via `Mcp-Session-Id` header
- Protocol version via `MCP-Protocol-Version` header (REQUIRED per clarification 2026-04-08)
- Origin header validation for security
- SSE polling pattern: Server sends initial event with ID and empty data, MAY close connection after response, clients reconnect using Last-Event-ID, server sends `retry` field before closing
**Integration Approaches Evaluated**:
**Option A: Native HTTP/1.1 + SSE (MCP Spec Compliant)** ⭐ SELECTED
```
Client → Node.js MCP Server (HTTP/1.1 + SSE via StreamableHTTPServerTransport)
```
- ✅ Direct implementation per MCP specification
- ✅ Zero code complexity - use SDK's `StreamableHTTPServerTransport` directly
- ✅ Single process deployment (no reverse proxy required for spec compliance)
- ✅ Simplified debugging and local development
- ✅ Meets all MCP security requirements (Origin validation, localhost binding)
- ⚠️ Optional: Can add Nginx/Caddy for TLS termination and HTTP/2 upgrade (production enhancement)
**Option B: Reverse Proxy with HTTP/2 Upgrade**
```
Client (HTTP/2) → Nginx/Caddy (HTTP/2 → HTTP/1.1) → Node.js MCP Server (HTTP/1.1+SSE)
```
- ✅ Adds HTTP/2 multiplexing for client connections
- ✅ TLS termination in reverse proxy
- ⚠️ Adds deployment complexity
- ⚠️ Not required for MCP specification compliance
**Rationale for Selection**:
- MCP specification explicitly defines Streamable HTTP as HTTP/1.1 + SSE
- HTTP/2 is an optional enhancement, not a requirement
- Simpler deployment path (single Node.js process)
**Implementation Strategy**:
```javascript
// src/transports/streamable-http.js
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import http from 'node:http';
import { randomUUID } from 'node:crypto';
export class HTTPTransport {
constructor(options = {}) {
this.port = options.port || 3000;
this.host = options.host || '127.0.0.1'; // Localhost for proxy
this.mcpTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: false // Use SSE streaming
});
this.server = http.createServer(async (req, res) => {
// CORS, rate limiting, health check middleware
// Then delegate to mcpTransport.handleRequest()
});
}
}
```
**Docker Configuration**:
```yaml
# docker-compose.yaml
services:
mcp-server:
environment:
TRANSPORT_MODE: http
HTTP_PORT: 3000
HTTP_HOST: 127.0.0.1 # Only accessible via nginx
nginx:
image: nginx:alpine
ports:
- "8080:8080" # External HTTP/2 port
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- mcp-server
```
**Nginx Configuration** (nginx/nginx.conf):
```nginx
server {
listen 8080 ssl http2;
server_name localhost;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
location /mcp {
proxy_pass http://mcp-server:3000;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# SSE support
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
location /health {
proxy_pass http://mcp-server:3000/health;
}
}
```
**Alternatives**: Future enhancement can add native HTTP/2 option for single-binary deployment.
---
### Decision 9: MCP Session Management for Remote Access
**Decision**: Stateful sessions with Valkey backing, MCP Session ID mapped to Valkey session
**Question**: How should connection lifecycle and session management work for remote MCP?
**Key Distinction**: HTTP/2 Stream ≠ MCP Session
- **HTTP/2 Stream**: Single request/response cycle, multiplexed over one TCP connection
- **MCP Session**: Persistent identifier (`sessionId`) spanning multiple HTTP requests
- **Valkey Session**: Business logic session tracking PNRs, searches, user context
**Session Lifecycle**:
1. **Initialize**: Client connects → Transport generates sessionId (UUID) → Create Valkey session
2. **Active**: Client makes requests with `MCP-Session-ID` header → Refresh session TTL
3. **Idle**: No requests for N minutes → Session remains in Valkey (TTL not expired)
4. **Expired**: TTL reaches zero → Valkey auto-deletes session data
5. **Reconnect**: Client can resume with same `MCP-Session-ID` header
**Storage Pattern**:
```
session:{sessionId}:metadata → { createdAt, lastActivityAt, transportType, remoteIP }
session:{sessionId}:searches → Recent search results (optional caching)
session:{sessionId}:pnrs → Set of PNR codes created in this session
pnr:{pnr} → Global PNR storage (not session-namespaced)
```
**Implementation**:
```javascript
// src/session/manager.js
async function handleToolCall(request, sessionId) {
if (!sessionId) throw new Error('Session ID required');
let valkeySession = await sessionManager.getSession(sessionId);
if (!valkeySession) {
valkeySession = await sessionManager.createSession(sessionId);
}
await sessionManager.updateActivity(sessionId);
return await toolHandler(request.params, sessionId);
}
```
**Session Isolation**: Each session has isolated Valkey namespace. PNRs stored globally (separate from sessions).
---
### Decision 10: IP-Based Rate Limiting Algorithm
**Decision**: Sliding Window Counter (Hybrid Approach)
**Question**: What rate limiting algorithm works for IP-based tracking without authentication?
**Algorithms Evaluated**:
1. **Fixed Window**: Simple but has burst problem (200 req in 1 second across window boundary)
2. **Sliding Window Log**: Accurate but high memory (stores timestamp per request)
3. **Sliding Window Counter**: Approximation balancing accuracy and performance ⭐ SELECTED
4. **Token Bucket**: Good for burst allowance but complex state management
**Selected Algorithm**: Sliding Window Counter
- ✅ Prevents large bursts (unlike fixed window)
- ✅ Low memory (2 counters per IP)
- ✅ Simple implementation (no Lua scripts required)
- ✅ Accuracy within 1-2% of perfect sliding window
**Implementation**:
```javascript
// src/remote/ratelimit.js
async function checkRateLimit(clientIP, limit = 100, windowSeconds = 60) {
const now = Math.floor(Date.now() / 1000);
const currentWindow = Math.floor(now / windowSeconds);
const previousWindow = currentWindow - 1;
const currentKey = `ratelimit:${clientIP}:${currentWindow}`;
const previousKey = `ratelimit:${clientIP}:${previousWindow}`;
const [currentCount, previousCount] = await Promise.all([
storage.incr(currentKey),
storage.get(previousKey) || 0
]);
if (currentCount === 1) {
await storage.expire(currentKey, windowSeconds * 2);
}
const elapsedInWindow = now % windowSeconds;
const previousWeight = 1 - (elapsedInWindow / windowSeconds);
const estimatedCount = (previousCount * previousWeight) + currentCount;
if (estimatedCount > limit) {
throw new RateLimitError({ limit, current: Math.floor(estimatedCount) });
}
return { allowed: true, remaining: limit - Math.floor(estimatedCount) };
}
```
**Performance**: ~3 Valkey ops per request, ~100 bytes per IP, within 1-2% of perfect sliding window.
**HTTP Headers**:
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1712486460
Retry-After: 15
```
**Client IP Extraction**:
```javascript
function getClientIP(request) {
const forwarded = request.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
const realIP = request.headers['x-real-ip'];
if (realIP) return realIP;
return request.socket.remoteAddress;
}
```
**Configuration**:
```bash
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=100
RATE_LIMIT_WINDOW_SECONDS=60
```
---
### Decision 11: Global PNR Storage with TTL
**Decision**: Global namespace with SETEX, independent PNR lifecycle from sessions
**Question**: How to implement global PNR retrieval across sessions with configurable expiration?
**Requirements**:
1. PNRs globally retrievable (any session can retrieve any PNR)
2. PNRs expire after TTL (default 1 hour)
3. PNR creation session logged but doesn't restrict retrieval
4. Session expiration doesn't delete PNRs
**Storage Pattern**:
**Global PNR** (not session-scoped):
```
pnr:TEST-ABC123 → { pnr, status, segments, passengers, createdAt, expiresAt, creatingSessionId }
```
**Session Reference** (for listBookings tool):
```
session:{sessionId}:pnrs → Set<pnr> // PNRs created in this session
```
**Implementation**:
```javascript
// Create PNR
async function createPNR(pnrData, ttlHours = 1) {
const pnr = generatePNR(); // TEST-XXXXXX
const ttlSeconds = ttlHours * 3600;
const pnrRecord = {
pnr,
status: 'confirmed',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
creatingSessionId: pnrData.sessionId, // For logging only
...pnrData
};
// Store globally with TTL
await storage.setex(`pnr:${pnr}`, ttlSeconds, JSON.stringify(pnrRecord));
// Add to session's created PNRs list
await storage.sadd(`session:${pnrData.sessionId}:pnrs`, pnr);
return pnrRecord;
}
// Retrieve PNR (global, any session)
async function retrieveBooking({ pnr }, sessionId) {
const pnrData = await storage.get(`pnr:${pnr}`);
if (!pnrData) {
throw new NotFoundError(`PNR ${pnr} not found or expired`);
}
return JSON.parse(pnrData);
}
// List PNRs created in session
async function listBookings({ limit = 10 }, sessionId) {
const pnrCodes = await storage.smembers(`session:${sessionId}:pnrs`);
const pnrs = await Promise.all(
pnrCodes.map(async (code) => {
const data = await storage.get(`pnr:${code}`);
return data ? JSON.parse(data) : null;
})
);
return pnrs.filter(Boolean).slice(0, limit);
}
```
**Edge Cases**:
1. **PNR Expired**: `retrieveBooking` returns "PNR not found or expired"
2. **Session Expires Before PNR**: PNR remains globally retrievable
3. **List After Session Expiry**: Returns empty (session reference deleted)
**Configuration**:
```bash
PNR_TTL_HOURS=1
SESSION_TTL_HOURS=24
```
**Storage Efficiency**: ~2KB per PNR, 1000 PNRs = 2MB, auto-cleanup via Valkey TTL.
---
### Decision 12: CORS Configuration
**Decision**: Permissive Wildcard CORS with Network-Level Access Control
**Question**: How to configure CORS for web-based MCP clients?
**CORS Policy**: `Access-Control-Allow-Origin: *` (wildcard)
**Rationale**:
- ✅ Maximum compatibility - any web client can connect from any domain
- ✅ Simplifies development - no origin whitelist configuration
- ✅ Enables browser tools, Chrome extensions, web IDEs
- ⚠️ Requires network-level security (firewall, VPN, private network)
- ⚠️ Only acceptable for trusted development/testing environments
**Security Implications**:
1. **No Credentials**: Wildcard incompatible with `Access-Control-Allow-Credentials: true` (acceptable - we have no auth)
2. **Public Data**: Any website can make requests (acceptable - test data only)
3. **CSRF Potential**: Limited risk (no authentication, state changes require valid PNR)
4. **Network Security**: Deploy within private networks, use firewall rules
**Implementation**:
```javascript
// src/remote/cors.js
export function applyCORS(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'false');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Session-ID');
res.setHeader('Access-Control-Expose-Headers', 'X-RateLimit-Limit, X-RateLimit-Remaining');
res.setHeader('Access-Control-Max-Age', '86400');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return true; // Handled
}
return false; // Continue
}
```
**Nginx Alternative**:
```nginx
location /mcp {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
return 204;
}
add_header Access-Control-Allow-Origin * always;
proxy_pass http://mcp-server:3000;
}
```
**Security Posture**: Wildcard CORS acceptable for mock server because:
1. Contains only test data (no sensitive information)
2. No authentication (no credentials to steal)
3. Network-level access controls provide security boundary
4. Maximizes developer flexibility for ad-hoc tooling
**Configuration**:
```bash
CORS_ENABLED=true
CORS_ORIGINS=*
CORS_MAX_AGE=86400
```
---
## Remote Access Technology Summary
| Component | Technology | Decision |
|-----------|-----------|----------|
| **HTTP/2 Server** | Nginx reverse proxy | Terminate HTTP/2, proxy to HTTP/1.1 |
| **MCP Transport** | StreamableHTTPServerTransport | Over HTTP/1.1 (proxied) |
| **Rate Limiting** | Sliding window counter | Valkey-backed, IP-based |
| **PNR Storage** | Global with TTL | Valkey SETEX, independent lifecycle |
| **CORS** | Wildcard policy | `Access-Control-Allow-Origin: *` |
| **Health Check** | Unauthenticated endpoint | `/health` returning JSON status |
## Environment Variables (Remote Mode)
```bash
# Transport
TRANSPORT_MODE=stdio|http|both
HTTP_PORT=3000
HTTP_HOST=127.0.0.1
# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=100
RATE_LIMIT_WINDOW_SECONDS=60
# PNR/Session
PNR_TTL_HOURS=1
SESSION_TTL_HOURS=24
# CORS
CORS_ENABLED=true
CORS_ORIGINS=*
CORS_MAX_AGE=86400
```
## Next Steps
All remote access research complete. Proceed to update:
1. ✅ data-model.md - Add RemoteConnection, RateLimitRecord, HealthStatus entities
2. ✅ contracts/ - Add health endpoint contract
3. ✅ quickstart.md - Add remote access setup instructions

View File

@@ -5,6 +5,22 @@
**Status**: Draft **Status**: Draft
**Input**: User description: "Build a Mock GDS MCP Server" **Input**: User description: "Build a Mock GDS MCP Server"
## Clarifications
### Session 2026-04-07
- Q: Authentication implementation approach for remote connections → A: Don't implement authentication at this time (authentication deferred to future version, remote access will be open/unauthenticated for initial implementation)
- Q: Remote transport protocol selection → A: HTTP/2 with streaming (modern HTTP version with multiplexed streams for efficient concurrent request handling)
- Q: Session expiry and PNR lifecycle behavior → A: Global with TTL - PNRs are globally retrievable for a configurable time period (e.g., 1 hour) after creation, regardless of which session created them, allowing cross-session retrieval for testing flexibility
- Q: CORS policy configuration for web clients → A: Allow all origins (*) - Permissive wildcard CORS policy allowing any web client to connect, suitable for open development/testing environments
- Q: Rate limiting identifier without authentication → A: Client IP address - Track request rates per source IP address to prevent abuse while supporting unauthenticated access
### Session 2026-04-08
- Q: MCP transport protocol implementation - correct specification compliance → A: Implement MCP Streamable HTTP spec (HTTP/1.1 + SSE) - Follow the official MCP 2025-11-25 specification: HTTP/1.1 with Server-Sent Events, single /mcp endpoint with POST/GET/DELETE methods, event IDs for resumability, MCP-Protocol-Version header handling. Optional Nginx reverse proxy can upgrade to HTTP/2 for clients while proxying HTTP/1.1 to the application.
- Q: SSE connection management strategy for resource efficiency → A: Implement SSE polling pattern with connection closure - Server sends initial event with ID and empty data to prime reconnection, then MAY close the HTTP connection (not stream) after sending responses to reduce resource usage. Clients reconnect using Last-Event-ID header to resume. Server sends retry field before closing connections per MCP 2025-11-25 specification.
- Q: MCP-Protocol-Version header validation behavior → A: Require MCP-Protocol-Version header on all requests - Reject requests without the header with 400 Bad Request immediately, forcing all clients to send version explicitly with no backward compatibility fallback.
## User Scenarios & Testing *(mandatory)* ## User Scenarios & Testing *(mandatory)*
### User Story 1 - Flight Search and Booking (Priority: P1) ### User Story 1 - Flight Search and Booking (Priority: P1)
@@ -72,6 +88,23 @@ A QA team runs automated integration tests that execute multiple booking scenari
--- ---
### User Story 6 - Remote Access for Distributed Teams (Priority: P2)
A distributed development team needs to access the mock GDS server from different locations without setting up local instances. Remote developers connect to a centrally hosted mock server over the internet and execute booking workflows from their local development environments or web-based tools. The remote server handles multiple concurrent connections while maintaining session isolation.
**Why this priority**: Enables distributed teams to share a single mock server instance, reducing setup overhead and ensuring consistency across team members. Remote access supports cloud-based development environments, CI/CD pipelines running in the cloud, and web-based MCP clients that cannot use local stdio connections.
**Independent Test**: Can be tested by deploying the server to a public endpoint, connecting from multiple external clients, executing parallel booking workflows, and verifying that each session maintains proper isolation and receives correct responses. Confirms remote multi-user capability.
**Acceptance Scenarios**:
1. **Given** a remote MCP client, **When** the client connects to the server's public endpoint, **Then** the system establishes a connection and responds to MCP tool calls with the same functionality as stdio mode
2. **Given** web-based MCP clients from different origins, **When** clients connect to the server, **Then** the system allows connections and handles cross-origin requests appropriately
3. **Given** multiple remote clients connected simultaneously, **When** each executes independent booking workflows, **Then** the system maintains separate session contexts and prevents data leakage between remote sessions
4. **Given** monitoring tools checking server availability, **When** health check endpoints are queried, **Then** the system returns status information indicating operational readiness
---
### User Story 5 - Realistic Test Data for Demonstrations (Priority: P3) ### User Story 5 - Realistic Test Data for Demonstrations (Priority: P3)
A sales team demonstrates travel booking software to potential customers. They need realistic, professional-looking mock data that appears authentic during presentations - valid airline names and codes, recognizable hotel chains, realistic pricing that matches market expectations, and diverse route options covering major travel markets (domestic US, transatlantic, Asia-Pacific). The mock server provides curated demonstration scenarios with high-quality data suitable for live demos. A sales team demonstrates travel booking software to potential customers. They need realistic, professional-looking mock data that appears authentic during presentations - valid airline names and codes, recognizable hotel chains, realistic pricing that matches market expectations, and diverse route options covering major travel markets (domestic US, transatlantic, Asia-Pacific). The mock server provides curated demonstration scenarios with high-quality data suitable for live demos.
@@ -94,7 +127,7 @@ A sales team demonstrates travel booking software to potential customers. They n
- **Booking Modifications**: How does the system handle attempts to modify an already-cancelled booking? System must reject modification requests and return clear error indicating booking is in cancelled state and cannot be changed. - **Booking Modifications**: How does the system handle attempts to modify an already-cancelled booking? System must reject modification requests and return clear error indicating booking is in cancelled state and cannot be changed.
- **Session Expiry**: What happens when a developer retrieves a PNR after the session expires or in a different session? System should handle this gracefully - either maintain PNRs across sessions (making them globally retrievable) or return clear error "PNR not found in current session" if session-scoped. - **Session Expiry**: PNRs remain globally retrievable for a configurable time period (default 1 hour) after creation, regardless of which session created them or whether that session is still active. After the TTL expires, PNR retrieval returns "PNR not found or expired" error. This allows testing scenarios where bookings need to be retrieved across different sessions or after reconnection.
- **Incomplete Booking Data**: How does system respond when booking request is missing required fields (no passenger name, no contact info)? System must validate required fields and return specific validation errors ("Missing required field: passenger.lastName", "Missing required field: contact.email"). - **Incomplete Booking Data**: How does system respond when booking request is missing required fields (no passenger name, no contact info)? System must validate required fields and return specific validation errors ("Missing required field: passenger.lastName", "Missing required field: contact.email").
@@ -106,6 +139,22 @@ A sales team demonstrates travel booking software to potential customers. They n
- **Multi-Service Timing Conflicts**: What happens when developer tries to add a hotel with dates that don't overlap with the flight dates in the same booking (flight on June 10, hotel June 20-22)? System should either warn about date mismatch or enforce logical consistency (hotel dates must overlap with travel period). - **Multi-Service Timing Conflicts**: What happens when developer tries to add a hotel with dates that don't overlap with the flight dates in the same booking (flight on June 10, hotel June 20-22)? System should either warn about date mismatch or enforce logical consistency (hotel dates must overlap with travel period).
### Remote Access Edge Cases
- **Rapid Connection Attempts**: What happens when a client attempts to connect 20 times in quick succession (potential connection retry loop)? System should handle connection rate limiting separately from request rate limiting, allowing reasonable reconnection attempts but preventing connection floods.
- **CORS Preflight Failures**: What happens when a web-based client from an allowed origin sends preflight OPTIONS requests? System must respond correctly to preflight requests to enable web clients to complete connection establishment.
- **Concurrent Requests from Same Remote Session**: What happens when a remote client sends 10 simultaneous booking requests in parallel using the same session? System should handle concurrent requests within a session appropriately - either process them in parallel maintaining consistency, or serialize them with appropriate queuing.
- **Health Check During High Load**: What happens when health check endpoint is queried while server is processing maximum concurrent connections? Health check should remain responsive (sub-second response) even under load, possibly with degraded status if resource utilization is high.
- **Rate Limit Edge Conditions**: What happens when a client makes exactly the limit number of requests at the boundary of a time window? System should handle edge cases consistently (requests at exactly the limit boundary should either all succeed or clearly fail with limit message, not random behavior).
- **Connection Cleanup on Client Crash**: What happens when a remote client crashes or loses network connectivity without gracefully closing the connection? System should detect inactive connections (no requests for N minutes) and clean up associated resources, freeing session data for garbage collection.
- **Invalid Origin Headers**: What happens when a remote client sends requests with spoofed or missing Origin headers? System should validate CORS based on request characteristics and either allow (if origin validation passes) or deny with appropriate CORS error.
## Requirements *(mandatory)* ## Requirements *(mandatory)*
### Functional Requirements ### Functional Requirements
@@ -142,7 +191,7 @@ A sales team demonstrates travel booking software to potential customers. They n
- **FR-016**: System MUST generate realistic pricing based on route distance, service class, and time factors - no arbitrary prices like $1 or $999999 - **FR-016**: System MUST generate realistic pricing based on route distance, service class, and time factors - no arbitrary prices like $1 or $999999
- **FR-017**: System MUST maintain booking state persistence within a session - bookings created during a session remain retrievable for the session duration - **FR-017**: System MUST maintain booking state persistence with configurable TTL (default 1 hour) - bookings remain globally retrievable from any session until TTL expiration, after which they return "not found" errors
- **FR-018**: System MUST validate all input parameters (date formats YYYY-MM-DD, valid airport codes, positive passenger counts, logical date sequences) and reject invalid requests with specific error messages - **FR-018**: System MUST validate all input parameters (date formats YYYY-MM-DD, valid airport codes, positive passenger counts, logical date sequences) and reject invalid requests with specific error messages
@@ -150,6 +199,40 @@ A sales team demonstrates travel booking software to potential customers. They n
- **FR-020**: System MUST prevent any connection attempts to real booking systems, payment processors, or external GDS providers - all operations are fully self-contained mock operations - **FR-020**: System MUST prevent any connection attempts to real booking systems, payment processors, or external GDS providers - all operations are fully self-contained mock operations
### Remote Access Functional Requirements
- **FR-021**: System MUST support both stdio transport (for local MCP clients) and remote transport (for internet-accessible MCP clients), with transport mode configurable via environment settings
- **FR-022**: System MUST implement MCP Streamable HTTP transport per specification 2025-11-25: HTTP/1.1 with Server-Sent Events (SSE), single /mcp endpoint supporting POST (client-to-server messages with SSE response), GET (server-to-client message stream), and DELETE (session termination) methods, event IDs for stream resumability, MCP-Protocol-Version header handling, and MCP-Session-Id header for session management. The application uses HTTP/1.1 + SSE; optional reverse proxy (Nginx/Caddy) may upgrade client connections to HTTP/2 while proxying HTTP/1.1 to the application.
- **FR-022a**: System MUST implement SSE polling pattern for resource efficiency: send initial event with ID and empty data field to prime client reconnection, MAY close HTTP connection (not stream) after sending responses to reduce resource usage, clients reconnect using Last-Event-ID header to resume stream, and server MUST send retry field (milliseconds) before closing connections to guide client reconnection timing per MCP specification.
- **FR-022b**: System MUST require MCP-Protocol-Version header on all remote HTTP requests and reject requests without this header with HTTP 400 Bad Request and error message indicating the missing required header. System MUST validate the header value and support protocol version 2025-11-25, rejecting unsupported versions with HTTP 400 Bad Request.
- **FR-023**: System MAY implement authentication for remote connections in future versions, but initial implementation allows unauthenticated access suitable for trusted development/testing environments
- **FR-024**: System MUST support cross-origin requests from web-based MCP clients using permissive CORS policy (Allow-Origin: *), enabling browser-based tools and applications from any domain to connect to the remote server
- **FR-025**: System MUST enforce rate limiting on remote connections based on client IP address to prevent abuse, with configurable limits for requests per IP per time period (e.g., 100 requests per minute per IP address)
- **FR-026**: System MUST validate all input data from remote connections with security checks protecting against malicious payloads or injection attacks
- **FR-027**: System MUST provide health check endpoints that return operational status without requiring authentication, enabling monitoring systems and load balancers to verify server availability
- **FR-028**: System MUST maintain session isolation for remote clients identical to stdio sessions - each remote connection receives a unique session identifier with complete data isolation from other remote and local sessions
- **FR-029**: System MUST handle multiple concurrent remote client connections (minimum 50 simultaneous remote sessions) while maintaining performance and isolation guarantees
- **FR-030**: System MUST support secure connections using standard encryption for remote transport, protecting data in transit from client to server
- **FR-031**: System MUST log connection events and rate limit violations for remote connections in addition to standard operation logging, supporting operational monitoring
- **FR-032**: System MUST allow transport mode to be selected at server startup (stdio-only, remote-only, or both) to support different deployment scenarios from local development to public cloud hosting
- **FR-033**: System MUST gracefully handle remote client disconnections, cleaning up session resources and maintaining data consistency even when clients disconnect unexpectedly
- **FR-034**: System MUST provide the same MCP tool functionality and response format for both stdio and remote connections, ensuring consistent behavior regardless of transport method
### Key Entities ### Key Entities
- **Flight Segment**: Represents one flight leg with departure/arrival airports (IATA codes), departure/arrival times (local time + UTC), airline code, flight number, aircraft type, duration, available fare classes (economy/business/first), pricing per class, and seat availability counts - **Flight Segment**: Represents one flight leg with departure/arrival airports (IATA codes), departure/arrival times (local time + UTC), airline code, flight number, aircraft type, duration, available fare classes (economy/business/first), pricing per class, and seat availability counts
@@ -160,14 +243,20 @@ A sales team demonstrates travel booking software to potential customers. They n
- **Passenger/Guest**: Represents traveler information with full name (first/middle/last), date of birth, contact details (email, phone), loyalty program numbers (frequent flyer, hotel rewards), and special requests/needs (meal preferences, accessibility requirements, seat preferences) - **Passenger/Guest**: Represents traveler information with full name (first/middle/last), date of birth, contact details (email, phone), loyalty program numbers (frequent flyer, hotel rewards), and special requests/needs (meal preferences, accessibility requirements, seat preferences)
- **PNR (Passenger Name Record)**: Core booking entity containing unique 6-character alphanumeric identifier, booking status (confirmed/cancelled/pending), creation timestamp, list of all service segments (flights, hotels, cars), passenger/guest details, total price summary, payment method placeholder, and session identifier - **PNR (Passenger Name Record)**: Core booking entity containing unique 6-character alphanumeric identifier, booking status (confirmed/cancelled/pending), creation timestamp, expiration timestamp (creation time + configurable TTL, default 1 hour), list of all service segments (flights, hotels, cars), passenger/guest details, total price summary, payment method placeholder, and originating session identifier for logging purposes. PNRs are globally retrievable from any session until TTL expiration.
- **Session**: Represents an MCP connection session with unique session ID, creation timestamp, list of active PNRs created in this session, session expiry time, and isolation boundary ensuring no cross-session data access - **Session**: Represents an MCP connection session with unique session ID, creation timestamp, session expiry time, and isolation boundary ensuring no cross-session data access during operation. Sessions track which PNRs were created during the session for logging but do not restrict retrieval - PNRs remain accessible globally based on their own TTL.
- **Search Query**: Represents search parameters for any service type with service type (flight/hotel/car), origin/destination or location, dates, passenger/guest counts, optional filters (price range, star rating, airline preference), and returns matching results list - **Search Query**: Represents search parameters for any service type with service type (flight/hotel/car), origin/destination or location, dates, passenger/guest counts, optional filters (price range, star rating, airline preference), and returns matching results list
- **Mock Data Record**: Represents static test data entries for airports (code, city, country, timezone), airlines (code, name, country), hotels (name, location, rating, base rates), and rental companies (name, code, vehicle inventory), all marked with "mock: true" flag - **Mock Data Record**: Represents static test data entries for airports (code, city, country, timezone), airlines (code, name, country), hotels (name, location, rating, base rates), and rental companies (name, code, vehicle inventory), all marked with "mock: true" flag
- **Remote Connection**: Represents an active remote client connection with connection identifier, remote client IP address (for logging and rate limiting), connection start timestamp, last activity timestamp, transport protocol details, and associated session identifier
- **Rate Limit Record**: Tracks request rates per client IP address with IP address identifier, time window start, request count in current window, limit threshold, and next reset timestamp, enabling enforcement of usage limits to prevent abuse from individual source addresses
- **Health Status**: Represents server operational status with overall health state (healthy/degraded/unhealthy), active connection counts (stdio and remote), resource utilization metrics, uptime duration, and any current issues or warnings
## Success Criteria *(mandatory)* ## Success Criteria *(mandatory)*
### Measurable Outcomes ### Measurable Outcomes
@@ -192,6 +281,24 @@ A sales team demonstrates travel booking software to potential customers. They n
- **SC-010**: Error messages enable developers to fix invalid requests on first attempt - each validation error specifies the exact field, expected format, and current invalid value - **SC-010**: Error messages enable developers to fix invalid requests on first attempt - each validation error specifies the exact field, expected format, and current invalid value
### Remote Access Success Criteria
- **SC-011**: Remote developers can connect to a deployed server instance from any location with internet access and complete booking workflows with the same sub-30-second performance as local stdio connections
- **SC-012**: System handles 50 concurrent remote clients executing independent booking workflows simultaneously while maintaining sub-2-second response times and complete session isolation
- **SC-013**: Web-based MCP clients from different domains can successfully connect and execute MCP tools without CORS-related failures, enabling browser-based development tools
- **SC-014**: Rate limiting prevents abuse while allowing legitimate usage - clients can execute at least 100 requests per minute under normal operation, with clear error messages when limits are exceeded
- **SC-015**: Health check endpoints respond within 500ms and accurately reflect server operational status, enabling effective monitoring and automated health checks by deployment platforms
- **SC-016**: Distributed teams can share a single remote server instance without coordination overhead - each developer receives isolated session data regardless of how many other team members are connected
- **SC-017**: Remote connections survive typical network interruptions - temporary disconnections of under 30 seconds result in automatic reconnection without data loss or session corruption
- **SC-018**: Transport mode configuration is clear and validated at startup - invalid configuration (missing required settings, contradictory options) results in immediate startup failure with specific error messages indicating the configuration problem
## Assumptions ## Assumptions
- **Mock Data Scope**: The mock database will include major US airports, international hubs (London, Paris, Tokyo, Dubai), and popular tourist destinations, but will not attempt to be comprehensive (all 40,000+ airports worldwide). Coverage focuses on common testing scenarios. - **Mock Data Scope**: The mock database will include major US airports, international hubs (London, Paris, Tokyo, Dubai), and popular tourist destinations, but will not attempt to be comprehensive (all 40,000+ airports worldwide). Coverage focuses on common testing scenarios.
@@ -200,7 +307,7 @@ A sales team demonstrates travel booking software to potential customers. They n
- **No Payment Processing**: All payment operations are placeholders - the system accepts payment method information (credit card type, last 4 digits) for workflow testing but performs no validation, tokenization, or processing of any kind. - **No Payment Processing**: All payment operations are placeholders - the system accepts payment method information (credit card type, last 4 digits) for workflow testing but performs no validation, tokenization, or processing of any kind.
- **Session Duration**: Sessions remain active for the duration of the MCP connection. When the connection closes, session data may be cleaned up. For persistent testing scenarios, developers should maintain their MCP connection or implement session rehydration in their test framework. - **Session Duration**: Sessions remain active for the duration of the MCP connection. When the connection closes, session metadata may be cleaned up, but PNRs created during that session remain retrievable globally based on their own TTL (default 1 hour from creation). This allows test scenarios to create bookings in one session and verify them in another.
- **Infinite Inventory**: Mock availability is effectively unlimited - any search returns available results, and any booking succeeds (unless specifically testing error scenarios). No real inventory tracking or "sold out" conditions unless explicitly requested in test scenario. - **Infinite Inventory**: Mock availability is effectively unlimited - any search returns available results, and any booking succeeds (unless specifically testing error scenarios). No real inventory tracking or "sold out" conditions unless explicitly requested in test scenario.
@@ -217,3 +324,29 @@ A sales team demonstrates travel booking software to potential customers. They n
- **No Authentication**: Session isolation is based on MCP connection identity, not user authentication. There is no login system, user accounts, or permission management. All sessions have equal access to all mock operations. - **No Authentication**: Session isolation is based on MCP connection identity, not user authentication. There is no login system, user accounts, or permission management. All sessions have equal access to all mock operations.
- **Development Environment**: The mock server is designed for development, testing, and demonstration environments. It is not intended for production use, load testing beyond moderate concurrency (50-100 sessions), or as a production GDS proxy. - **Development Environment**: The mock server is designed for development, testing, and demonstration environments. It is not intended for production use, load testing beyond moderate concurrency (50-100 sessions), or as a production GDS proxy.
### Remote Access Assumptions
- **Network Environment**: Remote deployments assume standard cloud hosting environments with public internet access and no unusual firewall restrictions. Complex enterprise network configurations (corporate proxies, VPNs, non-standard ports) may require additional configuration.
- **MCP Transport Protocol**: System implements MCP Streamable HTTP transport per specification 2025-11-25 using HTTP/1.1 with Server-Sent Events (SSE). Optional reverse proxy (Nginx, Caddy) may be deployed to upgrade client connections to HTTP/2 while proxying HTTP/1.1 to the application, but this is an infrastructure enhancement not required for MCP specification compliance.
- **No Authentication (Initial Version)**: Initial implementation provides open/unauthenticated remote access suitable for trusted development/testing environments. Authentication may be added in future versions when deployment to less trusted environments is required.
- **Rate Limiting Scope**: Rate limits are designed to prevent abuse and accidental infinite loops, not to implement billing tiers or usage tracking. Limits are set at reasonable levels for development usage (hundreds of requests per minute) and may be adjusted based on deployment capacity. Rate limiting may be based on client IP address or connection identifier rather than authenticated credentials.
- **Security Posture**: Remote access is appropriate for development/testing environments with trusted team members on trusted networks. Initial version without authentication is NOT designed for public internet exposure to untrusted users. Deployment should use network-level access controls (firewalls, VPNs, private networks) to limit access to authorized development teams.
- **CORS Configuration**: CORS support uses permissive wildcard policy (Access-Control-Allow-Origin: *) allowing connections from any web origin. This maximizes development flexibility - web-based tools, browser extensions, and client applications from any domain can connect without CORS configuration. Suitable for trusted development/testing networks where access control is managed at the network layer (firewalls, VPNs) rather than application layer.
- **Connection Persistence**: Remote connections are expected to be relatively short-lived (minutes to hours, not days). Long-lived connections are supported but may be subject to cleanup on server restart or maintenance. No connection migration or state persistence across server restarts.
- **Health Check Usage**: Health checks are designed for basic operational monitoring and load balancer integration. They report simple up/down status and basic metrics, not comprehensive performance monitoring or detailed diagnostics.
- **Transport Coexistence**: When both stdio and remote transport are enabled simultaneously, they share the same mock data and session isolation boundaries but operate independently. No cross-transport session sharing or migration.
- **Deployment Model**: Remote deployments assume container-based hosting (Docker) or similar cloud deployment models. The system does not include installation packages, system service configurations, or traditional server deployment tooling.
- **SSL/TLS Termination**: Secure connections assume standard SSL/TLS termination at the server or load balancer level. The mock server itself focuses on application-level functionality and relies on deployment infrastructure for transport security.
- **Monitoring Integration**: Basic health endpoints are provided, but comprehensive monitoring (metrics collection, distributed tracing, log aggregation) relies on standard deployment infrastructure and observability tools, not custom mock server monitoring features.

View File

@@ -19,11 +19,11 @@
**Purpose**: Project initialization and basic structure from plan.md **Purpose**: Project initialization and basic structure from plan.md
- [ ] T001 Initialize Node.js 20 project with package.json including @modelcontextprotocol/sdk, ioredis, pino dependencies - [X] T001 Initialize Node.js 20 project with package.json including @modelcontextprotocol/sdk, ioredis, pino dependencies
- [ ] T002 Create project directory structure: src/{tools,data,session,validation,utils}/, tests/{integration,unit,fixtures}/, docker/ - [X] T002 Create project directory structure: src/{tools,data,session,validation,utils}/, tests/{integration,unit,fixtures}/, docker/
- [ ] T003 [P] Configure ESLint and Prettier for code quality in .eslintrc.json and .prettierrc - [X] T003 [P] Configure ESLint and Prettier for code quality in .eslintrc.json and .prettierrc
- [ ] T004 [P] Create .dockerignore and .gitignore files for build optimization - [X] T004 [P] Create .dockerignore and .gitignore files for build optimization
- [ ] T005 [P] Create docker-compose.yaml with Valkey service configuration (port 6379, persistence enabled) - [X] T005 [P] Create docker-compose.yaml with Valkey service configuration (port 6379, persistence enabled)
--- ---
@@ -33,15 +33,15 @@
**⚠️ CRITICAL**: No user story work can begin until this phase is complete **⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [ ] T006 Implement Pino structured logger setup in src/utils/logger.js with configurable log levels - [X] T006 Implement Pino structured logger setup in src/utils/logger.js with configurable log levels
- [ ] T007 [P] Implement error handling utilities in src/utils/errors.js with MCP error codes - [X] T007 [P] Implement error handling utilities in src/utils/errors.js with MCP error codes
- [ ] T008 [P] Create Valkey client wrapper in src/session/storage.js with connection pooling and error handling - [X] T008 [P] Create Valkey client wrapper in src/session/storage.js with connection pooling and error handling
- [ ] T009 [P] Implement JSON schema validators in src/validation/validators.js using native validation - [X] T009 [P] Implement JSON schema validators in src/validation/validators.js using native validation
- [ ] T010 [P] Create MCP tool schemas in src/validation/schemas.js based on contracts/mcp-tools.md - [X] T010 [P] Create MCP tool schemas in src/validation/schemas.js based on contracts/mcp-tools.md
- [ ] T011 Create session lifecycle manager in src/session/manager.js with TTL management (1 hour default) - [X] T011 Create session lifecycle manager in src/session/manager.js with TTL management (1 hour default)
- [ ] T012 [P] Implement PNR generation utilities in src/data/pnr.js with TEST- prefix and base32 encoding - [X] T012 [P] Implement PNR generation utilities in src/data/pnr.js with TEST- prefix and base32 encoding
- [ ] T013 Initialize MCP server in src/server.js with @modelcontextprotocol/sdk Server class - [X] T013 Initialize MCP server in src/server.js with @modelcontextprotocol/sdk Server class
- [ ] T014 Create MCP server entry point in src/index.js with stdio transport and error handling - [X] T014 Create MCP server entry point in src/index.js with stdio transport and error handling
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel **Checkpoint**: Foundation ready - user story implementation can now begin in parallel
@@ -55,35 +55,35 @@
### Core Data for User Story 1 ### Core Data for User Story 1
- [ ] T015 [P] [US1] Create airports mock data in src/data/airports.js with 100+ major airports (IATA codes, names, cities, timezones, coordinates) - [X] T015 [P] [US1] Create airports mock data in src/data/airports.js with 100+ major airports (IATA codes, names, cities, timezones, coordinates)
- [ ] T016 [P] [US1] Create airlines mock data in src/data/airlines.js with 30+ carriers (IATA codes, names, countries) - [X] T016 [P] [US1] Create airlines mock data in src/data/airlines.js with 30+ carriers (IATA codes, names, countries)
- [ ] T017 [US1] Implement flight data generator in src/data/flights.js with deterministic pricing, duration calculation, and availability logic - [X] T017 [US1] Implement flight data generator in src/data/flights.js with deterministic pricing, duration calculation, and availability logic
### Flight Search Tool ### Flight Search Tool
- [ ] T018 [US1] Implement searchFlights tool handler in src/tools/flights.js with input validation (origin, destination, departureDate, passengers, cabin) - [X] T018 [US1] Implement searchFlights tool handler in src/tools/flights.js with input validation (origin, destination, departureDate, passengers, cabin)
- [ ] T019 [US1] Add flight search result generation with 3-5 mock flights per search, realistic schedules (6am-10pm departures), and price ranges ($200-$800 economy, $800-$2000 business, $2500+ first) - [X] T019 [US1] Add flight search result generation with 3-5 mock flights per search, realistic schedules (6am-10pm departures), and price ranges ($200-$800 economy, $800-$2000 business, $2500+ first)
- [ ] T020 [US1] Implement seat availability simulation (90% available, 10% sold out) and booking class assignment. **Note**: Implements infinite inventory model where concurrent bookings on same flight both succeed. This aligns with spec.md edge case discussion and is appropriate for mock server testing scope. - [X] T020 [US1] Implement seat availability simulation (90% available, 10% sold out) and booking class assignment. **Note**: Implements infinite inventory model where concurrent bookings on same flight both succeed. This aligns with spec.md edge case discussion and is appropriate for mock server testing scope.
### Flight Booking Tool ### Flight Booking Tool
- [ ] T021 [US1] Implement bookFlight tool handler in src/tools/flights.js with passenger validation (firstName, lastName, email, phone) - [X] T021 [US1] Implement bookFlight tool handler in src/tools/flights.js with passenger validation (firstName, lastName, email, phone)
- [ ] T022 [US1] Add flight booking creation logic: validate flight selection, generate PNR via src/data/pnr.js, persist to Valkey with key gds:session:{sessionId}:booking:{pnr} - [X] T022 [US1] Add flight booking creation logic: validate flight selection, generate PNR via src/data/pnr.js, persist to Valkey with key gds:session:{sessionId}:booking:{pnr}
- [ ] T023 [US1] Implement PNR storage structure in Valkey with FlightSegment, Passenger, pricing, status fields per data-model.md - [X] T023 [US1] Implement PNR storage structure in Valkey with FlightSegment, Passenger, pricing, status fields per data-model.md
- [ ] T024 [US1] Add session booking tracking: update gds:session:{sessionId}:bookings set and increment bookingCount - [X] T024 [US1] Add session booking tracking: update gds:session:{sessionId}:bookings set and increment bookingCount
### Booking Management Tools ### Booking Management Tools
- [ ] T025 [US1] Implement retrieveBooking tool handler in src/tools/bookings.js with PNR validation and Valkey lookup - [X] T025 [US1] Implement retrieveBooking tool handler in src/tools/bookings.js with PNR validation and Valkey lookup
- [ ] T026 [US1] Add booking retrieval logic: fetch from gds:session:{sessionId}:booking:{pnr}, return complete booking details with all segments - [X] T026 [US1] Add booking retrieval logic: fetch from gds:session:{sessionId}:booking:{pnr}, return complete booking details with all segments
- [ ] T027 [US1] Implement cancelBooking tool handler in src/tools/bookings.js with status transition validation (confirmed→cancelled only) - [X] T027 [US1] Implement cancelBooking tool handler in src/tools/bookings.js with status transition validation (confirmed→cancelled only)
- [ ] T028 [US1] Add cancellation logic: update booking status to 'cancelled', persist timestamp, return confirmation - [X] T028 [US1] Add cancellation logic: update booking status to 'cancelled', persist timestamp, return confirmation
### MCP Server Integration ### MCP Server Integration
- [ ] T029 [US1] Register searchFlights, bookFlight, retrieveBooking, cancelBooking tools in src/server.js with tool handlers - [X] T029 [US1] Register searchFlights, bookFlight, retrieveBooking, cancelBooking tools in src/server.js with tool handlers
- [ ] T030 [US1] Add request/response logging for all flight operations with session ID, operation type, parameters, and response times - [X] T030 [US1] Add request/response logging for all flight operations with session ID, operation type, parameters, and response times
- [ ] T031 [US1] Implement error handling for invalid airport codes, invalid dates, validation failures with specific error messages per FR-014 - [X] T031 [US1] Implement error handling for invalid airport codes, invalid dates, validation failures with specific error messages per FR-014
**FR-015 Coverage Note**: Multi-step booking workflows (search → price verification → select → confirm) are implemented through the task sequence T018-T028. The searchFlights tool (T018-T020) enables price verification, bookFlight (T021-T024) handles selection and confirmation, and retrieveBooking (T025-T026) supports workflow verification. **FR-015 Coverage Note**: Multi-step booking workflows (search → price verification → select → confirm) are implemented through the task sequence T018-T028. The searchFlights tool (T018-T020) enables price verification, bookFlight (T021-T024) handles selection and confirmation, and retrieveBooking (T025-T026) supports workflow verification.
@@ -99,29 +99,102 @@
### Session Infrastructure ### Session Infrastructure
- [ ] T032 [US4] Implement session creation logic in src/session/manager.js: generate UUID v4 session ID on MCP connection initialization - [X] T032 [US4] Implement session creation logic in src/session/manager.js: generate UUID v4 session ID on MCP connection initialization
- [ ] T033 [US4] Add session metadata storage in Valkey at gds:session:{sessionId} with createdAt, expiresAt, lastActivity, bookingCount, searchCount fields - [X] T033 [US4] Add session metadata storage in Valkey at gds:session:{sessionId} with createdAt, expiresAt, lastActivity, bookingCount, searchCount fields
- [ ] T034 [US4] Implement session TTL management: set EXPIRE on session keys (default 3600 seconds), refresh on activity - [X] T034 [US4] Implement session TTL management: set EXPIRE on session keys (default 3600 seconds), refresh on activity
- [ ] T035 [US4] Add session validation middleware in src/session/manager.js: verify session exists and not expired before tool execution - [X] T035 [US4] Add session validation middleware in src/session/manager.js: verify session exists and not expired before tool execution
### Session Isolation ### Session Isolation
- [ ] T036 [US4] Implement session-scoped key prefixing in src/session/storage.js: all Valkey keys include session ID for isolation - [X] T036 [US4] Implement session-scoped key prefixing in src/session/storage.js: all Valkey keys include session ID for isolation
- [ ] T037 [US4] Update booking storage to enforce session scope: gds:session:{sessionId}:booking:{pnr} pattern in all tools - [X] T037 [US4] Update booking storage to enforce session scope: gds:session:{sessionId}:booking:{pnr} pattern in all tools
- [ ] T038 [US4] Add session cleanup on expiry: implement background job or TTL-based cleanup for expired session data - [X] T038 [US4] Add session cleanup on expiry: implement background job or TTL-based cleanup for expired session data
- [ ] T039 [US4] Implement session statistics tracking: maintain gds:stats:sessions:active set, update gds:stats:bookings:total counter - [X] T039 [US4] Implement session statistics tracking: maintain gds:stats:sessions:active set, update gds:stats:bookings:total counter
### Session Validation ### Session Validation
- [ ] T040 [US4] Add cross-session isolation validation in retrieveBooking tool: verify PNR belongs to current session before returning - [X] T040 [US4] Add cross-session isolation validation in retrieveBooking tool: verify PNR belongs to current session before returning
- [ ] T041 [US4] Implement session activity tracking in src/session/manager.js: update lastActivity timestamp on every tool call - [X] T041 [US4] Implement session activity tracking in src/session/manager.js: update lastActivity timestamp on every tool call
- [ ] T042 [US4] Add session error responses for expired/invalid sessions with clear messages "Session expired" or "Session not found" - [X] T042 [US4] Add session error responses for expired/invalid sessions with clear messages "Session expired" or "Session not found"
**Checkpoint**: Multiple MCP sessions can now run concurrently with complete isolation - bookings in one session never appear in another session. **Checkpoint**: Multiple MCP sessions can now run concurrently with complete isolation - bookings in one session never appear in another session.
--- ---
## Phase 5: User Story 2 - Hotel Search and Multi-Service Bundling (Priority: P2) ## Phase 5: User Story 6 - Remote Access for Distributed Teams (Priority: P2)
**Goal**: Enable remote MCP access over Streamable HTTP (HTTP/1.1 + SSE per MCP 2025-11-25 specification) with rate limiting, CORS, health checks, and comprehensive security.
**Independent Test**: Start server with remote transport enabled, connect from remote MCP client over HTTP/1.1 using SSE for server-to-client messages and POST for client-to-server requests, execute flight search tool with MCP-Protocol-Version header, verify SSE polling pattern with connection closure and retry field, test Last-Event-ID resumption, verify rate limiting enforces 100 req/min default, test CORS preflight with wildcard origin, verify health endpoint returns service status, confirm MCP-Session-Id header management, confirm graceful shutdown preserves active sessions. All operations work independently of other user stories.
### Streamable HTTP Server Setup (MCP 2025-11-25 Compliant)
- [X] T043 [P] [US6] Update package.json with dependencies: @modelcontextprotocol/sdk for StreamableHTTPServerTransport, express for middleware
- [X] T044 [US6] Create Streamable HTTP server in src/transports/http-server.js using MCP SDK's StreamableHTTPServerTransport (HTTP/1.1 + SSE)
- [X] T045 [US6] Implement single /mcp endpoint supporting POST (client messages), GET (server message stream), DELETE (session termination) per MCP spec
- [X] T046 [US6] Add HTTP server lifecycle management: startup on configurable port, graceful shutdown (drain connections, 30s timeout), error handling
### SSE Polling Pattern Implementation (MCP 2025-11-25 Spec)
- [X] T047 [US6] Implement SSE event stream with unique event IDs for resumability in src/transports/sse-handler.js
- [X] T048 [US6] Add initial SSE event with ID and empty data field to prime client reconnection per MCP polling pattern
- [X] T049 [US6] Implement connection closure after response with `retry` field (default: 5000ms) to guide client reconnection timing
- [X] T050 [US6] Add Last-Event-ID header support for stream resumption when clients reconnect after disconnection
### MCP Protocol Version Validation
- [X] T051 [US6] Create protocol version middleware in src/middleware/protocol-version.js
- [X] T052 [US6] Implement MCP-Protocol-Version header validation: require header on all requests, reject missing/invalid versions with 400 Bad Request
- [X] T053 [US6] Add supported version check: accept 2025-11-25, reject unsupported versions with clear error message
### Session Management (MCP-Session-Id Header)
- [X] T054 [US6] Implement MCP-Session-Id header handling in src/session/session-manager.js
- [X] T055 [US6] Add session ID generation on InitializeResult response (cryptographically secure UUID)
- [X] T056 [US6] Validate MCP-Session-Id on subsequent requests: respond with 404 if session expired/not found
- [X] T057 [US6] Implement DELETE /mcp handler for explicit session termination
### Rate Limiting
- [X] T058 [P] [US6] Install express-rate-limit package in package.json for IP-based rate limiting
- [X] T059 [US6] Create rate limiter middleware in src/middleware/rate-limit.js with configurable limits (default: 100 req/min per IP)
- [X] T060 [US6] Implement rate limit enforcement: return 429 Too Many Requests with Retry-After header when limit exceeded
- [X] T061 [US6] Add rate limit headers to all responses: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
- [X] T062 [US6] Implement rate limit storage in Valkey: use sliding window counter with key expiration (60s TTL)
### CORS Configuration (Wildcard Policy)
- [X] T063 [P] [US6] Install cors package in package.json for CORS middleware
- [X] T064 [US6] Create CORS handler in src/middleware/cors.js with permissive wildcard policy (Access-Control-Allow-Origin: *)
- [X] T065 [US6] Implement CORS preflight handling for OPTIONS requests: return allowed methods (GET, POST, DELETE, OPTIONS), headers (Content-Type, MCP-Session-Id, MCP-Protocol-Version)
- [X] T066 [US6] Add Origin header validation per MCP security requirement: respond with 403 Forbidden if Origin present and validation fails
### Health Checks
- [X] T067 [US6] Implement health check endpoint in src/remote/health.js returning GET /health with JSON status response
- [X] T068 [US6] Add service health indicators: Valkey connection status, active session count, uptime, memory usage
- [X] T069 [US6] Implement readiness check: return 503 Service Unavailable if Valkey is disconnected or critical services unavailable
- [X] T070 [US6] Add health check logging: log every 10th health check to avoid log spam, log all failed health checks immediately
### Security & Transport Selection
- [X] T071 [US6] Create transport factory in src/transports/factory.js to select stdio or Streamable HTTP based on TRANSPORT environment variable
- [X] T072 [US6] Implement environment-based configuration in src/config/remote.js: PORT (default: 3000), HOST (default: 127.0.0.1 per MCP security), RATE_LIMIT_MAX, CORS_ORIGINS
- [X] T073 [US6] Add request logging middleware in src/middleware/logger.js: log method, path, IP, User-Agent, MCP-Protocol-Version, duration, status code
- [X] T074 [US6] Implement localhost binding (127.0.0.1) by default per MCP security recommendation, document production deployment with reverse proxy
### Integration & Testing Infrastructure
- [X] T075 [US6] Update src/index.js to support dual transport modes: stdio (default) or Streamable HTTP (when TRANSPORT=http or --remote flag)
- [X] T076 [US6] Add CLI argument parsing in src/index.js: --remote (enable HTTP), --port <number>, --host <address>, --verbose, --log-level <level>
- [X] T077 [US6] Create remote access example client in tests/fixtures/remote-client.js demonstrating SSE connection, Last-Event-ID resumption, and tool invocation
- [X] T078 [US6] Add nginx.conf.example in docker/ for optional HTTP/2 upgrade reverse proxy (client-facing HTTP/2, backend HTTP/1.1)
**Checkpoint**: Remote access complete - developers can connect to MCP server over Streamable HTTP (HTTP/1.1 + SSE per MCP 2025-11-25 spec), enforced rate limits, wildcard CORS, MCP-Protocol-Version validation, SSE polling pattern with connection closure, Last-Event-ID resumption, and health monitoring. Transport selection via TRANSPORT env var or --remote flag.
---
## Phase 6: User Story 2 - Hotel Search and Multi-Service Bundling (Priority: P2)
**Goal**: Enable developers to test hotel search and multi-service booking workflows. Search hotels, create hotel bookings, and bundle hotels with flights under single PNR. **Goal**: Enable developers to test hotel search and multi-service booking workflows. Search hotels, create hotel bookings, and bundle hotels with flights under single PNR.
@@ -129,34 +202,34 @@
### Hotel Data ### Hotel Data
- [ ] T043 [P] [US2] Create hotels mock data in src/data/hotels.js with 50+ properties across major cities (names, chains, star ratings, addresses, amenities) - [X] T079 [P] [US2] Create hotels mock data in src/data/hotels.js with 50+ properties across major cities (names, chains, star ratings, addresses, amenities)
- [ ] T044 [US2] Implement hotel data generator with realistic pricing tiers: budget $80-$150, midrange $150-$300, luxury $300-$800 per night - [X] T080 [US2] Implement hotel data generator with realistic pricing tiers: budget $80-$150, midrange $150-$300, luxury $300-$800 per night
### Hotel Search Tool ### Hotel Search Tool
- [ ] T045 [US2] Implement searchHotels tool handler in src/tools/hotels.js with input validation (cityCode, checkInDate, checkOutDate, guests) - [X] T081 [US2] Implement searchHotels tool handler in src/tools/hotels.js with input validation (cityCode, checkInDate, checkOutDate, guests)
- [ ] T046 [US2] Add hotel search result generation: 5-10 properties per search, calculate nights and total prices, include amenities (WiFi, parking, breakfast, gym, pool) - [X] T082 [US2] Add hotel search result generation: 5-10 properties per search, calculate nights and total prices, include amenities (WiFi, parking, breakfast, gym, pool)
- [ ] T047 [US2] Implement date validation: checkInDate < checkOutDate, minimum 1 night stay, no past dates - [X] T083 [US2] Implement date validation: checkInDate < checkOutDate, minimum 1 night stay, no past dates
### Hotel Booking Tool ### Hotel Booking Tool
- [ ] T048 [US2] Implement bookHotel tool handler in src/tools/hotels.js with guest validation and room selection - [X] T084 [US2] Implement bookHotel tool handler in src/tools/hotels.js with guest validation and room selection
- [ ] T049 [US2] Add hotel booking creation: generate or use existing PNR, persist HotelReservation to Valkey with check-in/check-out dates, room type, pricing - [X] T085 [US2] Add hotel booking creation: generate or use existing PNR, persist HotelReservation to Valkey with check-in/check-out dates, room type, pricing
- [ ] T050 [US2] Implement multi-service bundling logic in src/tools/hotels.js: add hotel to existing PNR if provided, create new PNR if not - [X] T086 [US2] Implement multi-service bundling logic in src/tools/hotels.js: add hotel to existing PNR if provided, create new PNR if not
### Multi-Service Integration ### Multi-Service Integration
- [ ] T051 [US2] Update PNR structure in src/data/pnr.js to support multiple service segments: flights[], hotels[], cars[] arrays - [X] T087 [US2] Update PNR structure in src/data/pnr.js to support multiple service segments: flights[], hotels[], cars[] arrays
- [ ] T052 [US2] Implement total price calculation across all segments: sum flight prices + hotel prices, update PNR totalPrice field - [X] T088 [US2] Implement total price calculation across all segments: sum flight prices + hotel prices, update PNR totalPrice field
- [ ] T053 [US2] Add date consistency validation: hotel dates should overlap with flight dates, warn if hotel is outside travel period - [X] T089 [US2] Add date consistency validation: hotel dates should overlap with flight dates, warn if hotel is outside travel period
- [ ] T054 [US2] Update retrieveBooking tool in src/tools/bookings.js to return complete multi-service itineraries with all segment types - [X] T090 [US2] Update retrieveBooking tool in src/tools/bookings.js to return complete multi-service itineraries with all segment types
- [ ] T055 [US2] Register searchHotels and bookHotel tools in src/server.js with tool handlers - [X] T091 [US2] Register searchHotels and bookHotel tools in src/server.js with tool handlers
**Checkpoint**: Developers can now search hotels, create hotel bookings, and bundle hotels with flights. Both flight-only and flight+hotel bookings work independently. **Checkpoint**: Developers can now search hotels, create hotel bookings, and bundle hotels with flights. Both flight-only and flight+hotel bookings work independently.
--- ---
## Phase 6: User Story 3 - Car Rental and Complete Travel Package (Priority: P3) ## Phase 7: User Story 3 - Car Rental and Complete Travel Package (Priority: P3)
**Goal**: Complete the full travel package capability by adding car rentals. Search cars, create car bookings, and bundle with flights+hotels for complete end-to-end travel itineraries. **Goal**: Complete the full travel package capability by adding car rentals. Search cars, create car bookings, and bundle with flights+hotels for complete end-to-end travel itineraries.
@@ -164,33 +237,33 @@
### Car Rental Data ### Car Rental Data
- [ ] T056 [P] [US3] Create car rental mock data in src/data/cars.js with 6+ rental companies (Hertz, Avis, Enterprise codes and names) - [X] T092 [P] [US3] Create car rental mock data in src/data/cars.js with 6+ rental companies (Hertz, Avis, Enterprise codes and names)
- [ ] T057 [US3] Implement car rental data generator with vehicle classes: economy $35-$50, midsize $50-$80, SUV/luxury $100-$150 per day - [X] T093 [US3] Implement car rental data generator with vehicle classes: economy $35-$50, midsize $50-$80, SUV/luxury $100-$150 per day
### Car Rental Search Tool ### Car Rental Search Tool
- [ ] T058 [US3] Implement searchCars tool handler in src/tools/cars.js with input validation (pickupLocation, pickupDate, dropoffLocation, dropoffDate) - [X] T094 [US3] Implement searchCars tool handler in src/tools/cars.js with input validation (pickupLocation, pickupDate, dropoffLocation, dropoffDate)
- [ ] T059 [US3] Add car search result generation: 4-6 vehicle classes per search, calculate rental days and total prices, include vehicle specs and mileage policies - [X] T095 [US3] Add car search result generation: 4-6 vehicle classes per search, calculate rental days and total prices, include vehicle specs and mileage policies
- [ ] T060 [US3] Implement date validation: pickupDate < dropoffDate, minimum 1 day rental, location validation (airport/city codes) - [X] T096 [US3] Implement date validation: pickupDate < dropoffDate, minimum 1 day rental, location validation (airport/city codes)
### Car Rental Booking Tool ### Car Rental Booking Tool
- [ ] T061 [US3] Implement bookCar tool handler in src/tools/cars.js with driver validation and vehicle selection - [X] T097 [US3] Implement bookCar tool handler in src/tools/cars.js with driver validation and vehicle selection
- [ ] T062 [US3] Add car rental booking creation: generate or use existing PNR, persist CarRental to Valkey with pickup/dropoff details, vehicle class, pricing - [X] T098 [US3] Add car rental booking creation: generate or use existing PNR, persist CarRental to Valkey with pickup/dropoff details, vehicle class, pricing
- [ ] T063 [US3] Implement car bundling logic in src/tools/cars.js: add car to existing PNR, validate dates align with flight arrival/departure - [X] T099 [US3] Implement car bundling logic in src/tools/cars.js: add car to existing PNR, validate dates align with flight arrival/departure
### Complete Package Integration ### Complete Package Integration
- [ ] T064 [US3] Update total price calculation in src/data/pnr.js to include car rental prices: sum flights + hotels + cars - [X] T100 [US3] Update total price calculation in src/data/pnr.js to include car rental prices: sum flights + hotels + cars
- [ ] T065 [US3] Add chronological itinerary ordering in retrieveBooking: sort segments by date/time (flight arrival → car pickup → hotel check-in → hotel check-out → car dropoff → return flight) - [X] T101 [US3] Add chronological itinerary ordering in retrieveBooking: sort segments by date/time (flight arrival → car pickup → hotel check-in → hotel check-out → car dropoff → return flight)
- [ ] T066 [US3] Implement date consistency validation for cars: pickup should align with flight arrival, dropoff should align with departure - [X] T102 [US3] Implement date consistency validation for cars: pickup should align with flight arrival, dropoff should align with departure
- [ ] T067 [US3] Register searchCars and bookCar tools in src/server.js with tool handlers - [X] T103 [US3] Register searchCars and bookCar tools in src/server.js with tool handlers
**Checkpoint**: Full GDS capability is now complete - developers can create comprehensive travel packages with flights, hotels, and cars all bundled under one PNR. **Checkpoint**: Full GDS capability is now complete - developers can create comprehensive travel packages with flights, hotels, and cars all bundled under one PNR.
--- ---
## Phase 7: User Story 5 - Realistic Test Data for Demonstrations (Priority: P3) ## Phase 8: User Story 5 - Realistic Test Data for Demonstrations (Priority: P3)
**Goal**: Polish mock data quality to enable professional sales demonstrations and training scenarios with recognizable brands, realistic pricing, and diverse route coverage. **Goal**: Polish mock data quality to enable professional sales demonstrations and training scenarios with recognizable brands, realistic pricing, and diverse route coverage.
@@ -198,76 +271,76 @@
### Enhanced Mock Data Quality ### Enhanced Mock Data Quality
- [ ] T068 [P] [US5] Expand airports mock data in src/data/airports.js to include 50+ international hubs (London LHR, Paris CDG, Tokyo NRT, Dubai DXB, etc.) - [X] T104 [P] [US5] Expand airports mock data in src/data/airports.js to include 50+ international hubs (London LHR, Paris CDG, Tokyo NRT, Dubai DXB, etc.)
- [ ] T069 [P] [US5] Add major hotel chains to src/data/hotels.js: Marriott, Hilton, Hyatt, IHG properties in 20+ major cities worldwide - [X] T105 [P] [US5] Add major hotel chains to src/data/hotels.js: Marriott, Hilton, Hyatt, IHG properties in 20+ major cities worldwide
- [ ] T070 [P] [US5] Enhance airline data in src/data/airlines.js with international carriers: British Airways, Virgin Atlantic, Lufthansa, Emirates, ANA, JAL - [X] T106 [P] [US5] Enhance airline data in src/data/airlines.js with international carriers: British Airways, Virgin Atlantic, Lufthansa, Emirates, ANA, JAL
### Route-Specific Pricing ### Route-Specific Pricing
- [ ] T071 [US5] Implement distance-based pricing in src/data/flights.js: calculate flight distance from coordinates, apply pricing tiers (short-haul $200-$400, medium-haul $400-$800, long-haul $800-$2000) - [X] T107 [US5] Implement distance-based pricing in src/data/flights.js: calculate flight distance from coordinates, apply pricing tiers (short-haul $200-$400, medium-haul $400-$800, long-haul $800-$2000)
- [ ] T072 [US5] Add realistic flight durations per route in src/data/flights.js: JFK-LAX ~6 hours, JFK-LHR ~7 hours, LAX-TYO ~12 hours - [X] T108 [US5] Add realistic flight durations per route in src/data/flights.js: JFK-LAX ~6 hours, JFK-LHR ~7 hours, LAX-TYO ~12 hours
- [ ] T073 [US5] Implement time-of-day departure variety: morning (6-9am), midday (9am-2pm), afternoon (2-5pm), evening (5-10pm) for realistic schedule diversity - [X] T109 [US5] Implement time-of-day departure variety: morning (6-9am), midday (9am-2pm), afternoon (2-5pm), evening (5-10pm) for realistic schedule diversity
### Premium Cabin Classes ### Premium Cabin Classes
- [ ] T074 [US5] Add premium cabin pricing in src/data/flights.js: economy baseline, premium economy +40%, business +200%, first class +400% - [X] T110 [US5] Add premium cabin pricing in src/data/flights.js: economy baseline, premium economy +40%, business +200%, first class +400%
- [ ] T075 [US5] Implement cabin-specific availability and booking classes: Y/B/M for economy, W for premium economy, J/C for business, F/A for first - [X] T111 [US5] Implement cabin-specific availability and booking classes: Y/B/M for economy, W for premium economy, J/C for business, F/A for first
- [ ] T076 [US5] Add cabin-specific amenities in flight results: economy (standard seat), business (lie-flat, lounge access), first (suites, premium dining) - [X] T112 [US5] Add cabin-specific amenities in flight results: economy (standard seat), business (lie-flat, lounge access), first (suites, premium dining)
### Demo Scenarios Configuration ### Demo Scenarios Configuration
- [ ] T077 [US5] Create demo seed data option in src/data/flights.js: when MOCK_DATA_SEED=demo, return curated high-quality results for common demo routes - [X] T113 [US5] Create demo seed data option in src/data/flights.js: when MOCK_DATA_SEED=demo, return curated high-quality results for common demo routes
- [ ] T078 [US5] Add configurable response delay in src/utils/logger.js via MOCK_RESPONSE_DELAY env var to simulate realistic search response times during demos - [X] T114 [US5] Add configurable response delay in src/utils/logger.js via MOCK_RESPONSE_DELAY env var to simulate realistic search response times during demos
- [ ] T079 [US5] Implement metadata tagging in all responses: add "data_source": "mock" field to all search results and bookings per FR-012 - [X] T115 [US5] Implement metadata tagging in all responses: add "data_source": "mock" field to all search results and bookings per FR-012
**Checkpoint**: Mock data is now demo-quality - professional appearance, recognizable brands, realistic pricing suitable for sales presentations and training without requiring explanations. **Checkpoint**: Mock data is now demo-quality - professional appearance, recognizable brands, realistic pricing suitable for sales presentations and training without requiring explanations.
--- ---
## Phase 8: Additional Booking Management (Supporting Tools) ## Phase 9: Additional Booking Management (Supporting Tools)
**Goal**: Add convenience tool for listing all bookings in a session (supports User Stories 1-4). **Goal**: Add convenience tool for listing all bookings in a session (supports User Stories 1-4).
**Independent Test**: Create multiple bookings (flights, hotels, cars) in a session, call listBookings tool, verify it returns all PNRs created in the session with summary information. Provides developers quick overview of session state. **Independent Test**: Create multiple bookings (flights, hotels, cars) in a session, call listBookings tool, verify it returns all PNRs created in the session with summary information. Provides developers quick overview of session state.
- [ ] T080 [US1] Implement listBookings tool handler in src/tools/bookings.js to retrieve all PNRs in current session - [X] T116 [US1] Implement listBookings tool handler in src/tools/bookings.js to retrieve all PNRs in current session
- [ ] T081 [US1] Add booking list query: read gds:session:{sessionId}:bookings set, fetch summary for each PNR (pnr, status, createdAt, totalPrice, segment counts) - [X] T117 [US1] Add booking list query: read gds:session:{sessionId}:bookings set, fetch summary for each PNR (pnr, status, createdAt, totalPrice, segment counts)
- [ ] T082 [US1] Register listBookings tool in src/server.js with tool handler - [X] T118 [US1] Register listBookings tool in src/server.js with tool handler
- [ ] T083 [US1] Add pagination support for listBookings if session has 10+ bookings: limit and offset parameters - [X] T119 [US1] Add pagination support for listBookings if session has 10+ bookings: limit and offset parameters
--- ---
## Phase 9: Docker Packaging ## Phase 10: Docker Packaging
**Purpose**: Container packaging for deployment and distribution **Purpose**: Container packaging for deployment and distribution
- [ ] T084 Create multi-stage Dockerfile in docker/Dockerfile: builder stage (install deps, copy source) and production stage (Node.js 20 Alpine, non-root user) - [X] T120 Create multi-stage Dockerfile in docker/Dockerfile: builder stage (install deps, copy source) and production stage (Node.js 20 Alpine, non-root user)
- [ ] T085 Create docker-bake.hcl in docker/ for multi-platform builds: linux/amd64 and linux/arm64 targets - [X] T121 Create docker-bake.hcl in docker/ for multi-platform builds: linux/amd64 and linux/arm64 targets
- [ ] T086 [P] Add health check to Dockerfile: verify Valkey connection and MCP server readiness - [X] T122 [P] Add health check to Dockerfile: verify Valkey connection and MCP server readiness
- [ ] T087 [P] Create .env.example file with all configuration variables documented (MCP, Valkey, logging, mock data settings) - [X] T123 [P] Create .env.example file with all configuration variables documented (MCP, Valkey, logging, mock data settings)
- [ ] T088 Update docker-compose.yaml to include gds-mock-mcp service with Valkey dependency and environment variable mapping - [X] T124 Update docker-compose.yaml to include gds-mock-mcp service with Valkey dependency and environment variable mapping
- [ ] T089 Add build and run scripts in package.json: npm run docker:build, npm run docker:run, npm run docker:down - [X] T125 Add build and run scripts in package.json: npm run docker:build, npm run docker:run, npm run docker:down
--- ---
## Phase 10: Documentation & Polish ## Phase 11: Documentation & Polish
**Purpose**: User-facing documentation, validation, and final polish **Purpose**: User-facing documentation, validation, and final polish
- [ ] T090 [P] Create comprehensive README.md: installation instructions, configuration reference, Docker usage, MCP tool documentation, troubleshooting guide - [X] T126 [P] Create comprehensive README.md: installation instructions, configuration reference, Docker usage, MCP tool documentation, troubleshooting guide
- [ ] T091 [P] Add SAFETY_DISCLAIMER.md: prominent "FOR TESTING AND DEMO PURPOSES ONLY" notice, explanation of TEST- prefix, no real transactions guarantee - [X] T127 [P] Add SAFETY_DISCLAIMER.md: prominent "FOR TESTING AND DEMO PURPOSES ONLY" notice, explanation of TEST- prefix, no real transactions guarantee
**Success Criteria Alignment**: The README (T090) and quickstart guide (T091) should emphasize: **Success Criteria Alignment**: The README (T090) and quickstart guide (T091) should emphasize:
- **SC-006**: Professional, demo-quality data requiring zero disclaimers or explanations during sales demonstrations - **SC-006**: Professional, demo-quality data requiring zero disclaimers or explanations during sales demonstrations
- **SC-007**: Clear documentation enabling new developers to understand GDS workflows within 15 minutes using realistic examples as learning material - **SC-007**: Clear documentation enabling new developers to understand GDS workflows within 15 minutes using realistic examples as learning material
These documentation objectives ensure the mock server serves both testing and training purposes effectively. These documentation objectives ensure the mock server serves both testing and training purposes effectively.
- [ ] T092 [P] Create CHANGELOG.md documenting feature implementation and version history - [X] T128 [P] Create CHANGELOG.md documenting feature implementation and version history
- [ ] T093 [P] Add inline code documentation: JSDoc comments for all public functions, tool handlers, data generators - [X] T129 [P] Add inline code documentation: JSDoc comments for all public functions, tool handlers, data generators
- [ ] T094 Validate quickstart.md examples: test all example commands in quickstart.md work correctly with current implementation - [X] T130 Validate quickstart.md examples: test all example commands in quickstart.md work correctly with current implementation
- [ ] T095 Add logging coverage review: ensure all operations log appropriately (search, book, retrieve, cancel, errors) with sufficient detail per FR-013 - [X] T131 Add logging coverage review: ensure all operations log appropriately (search, book, retrieve, cancel, errors) with sufficient detail per FR-013
- [ ] T096 Perform security review: verify no production credentials, no external API calls, TEST- prefix enforcement, non-root Docker user per Constitution Principle III - [X] T132 Perform security review: verify no production credentials, no external API calls, TEST- prefix enforcement, non-root Docker user per Constitution Principle III
- [ ] T097 Run constitution compliance check: verify all 6 principles (MCP protocol, mock data realism, no real transactions, tool architecture, session management, observability) - [X] T133 Run constitution compliance check: verify all 6 principles (MCP protocol, mock data realism, no real transactions, tool architecture, session management, observability)
--- ---
@@ -311,13 +384,15 @@ These documentation objectives ensure the mock server serves both testing and tr
**User Story 1**: T015 [P] + T016 [P] (data files), then T018-T031 sequentially **User Story 1**: T015 [P] + T016 [P] (data files), then T018-T031 sequentially
**User Story 2**: T043 [P] can run in parallel with US1 tasks **User Story 6**: T043 [P] + T058 [P] + T063 [P] can run in parallel with US1 tasks
**User Story 3**: T056 [P] + T057 [P] can run in parallel with US1/US2 tasks **User Story 2**: T079 [P] can run in parallel with US1/US6 tasks
**User Story 5**: T068 [P] + T069 [P] + T070 [P] can run in parallel **User Story 3**: T092 [P] + T093 [P] can run in parallel with US1/US2/US6 tasks
**User Stories across teams**: Once Foundational complete, different developers can work on US1, US2, US3, US4, US5 in parallel **User Story 5**: T104 [P] + T105 [P] + T106 [P] can run in parallel
**User Stories across teams**: Once Foundational complete, different developers can work on US1, US2, US3, US4, US5, US6 in parallel
--- ---
@@ -332,14 +407,17 @@ Task T015-T031: Flight search and booking implementation
# Developer B: User Story 4 (Session Management) # Developer B: User Story 4 (Session Management)
Task T032-T042: Concurrent session isolation Task T032-T042: Concurrent session isolation
# Developer C: User Story 2 (Hotels) # Developer C: User Story 6 (Remote Access)
Task T043-T055: Hotel search and bundling Task T043-T078: Streamable HTTP transport with SSE polling
# Developer D: User Story 3 (Cars) # Developer D: User Story 2 (Hotels)
Task T056-T067: Car rental integration Task T079-T091: Hotel search and bundling
# Developer E: User Story 5 (Demo Data) # Developer E: User Story 3 (Cars)
Task T068-T079: Enhanced mock data quality Task T092-T103: Car rental integration
# Developer F: User Story 5 (Demo Data)
Task T104-T115: Enhanced mock data quality
``` ```
--- ---
@@ -361,10 +439,11 @@ Task T068-T079: Enhanced mock data quality
1. Foundation (Setup + Foundational) → T001-T014 complete 1. Foundation (Setup + Foundational) → T001-T014 complete
2. **MVP Release**: Add US1 (T015-T031) → Test independently → Deploy v0.1 2. **MVP Release**: Add US1 (T015-T031) → Test independently → Deploy v0.1
3. **Multi-User Release**: Add US4 (T032-T042) → Test concurrency → Deploy v0.2 3. **Multi-User Release**: Add US4 (T032-T042) → Test concurrency → Deploy v0.2
4. **Hotel Bundling**: Add US2 (T043-T055) → Test multi-service → Deploy v0.3 4. **Remote Access**: Add US6 (T043-T078) → Test Streamable HTTP + SSE → Deploy v0.3
5. **Full Package**: Add US3 (T056-T067) → Test complete packages → Deploy v0.4 5. **Hotel Bundling**: Add US2 (T079-T091) → Test multi-service → Deploy v0.4
6. **Demo Quality**: Add US5 (T068-T079) → Polish for presentations → Deploy v1.0 6. **Full Package**: Add US3 (T092-T103) → Test complete packages → Deploy v0.5
7. Each increment adds value without breaking previous functionality 7. **Demo Quality**: Add US5 (T104-T115) → Polish for presentations → Deploy v1.0
8. Each increment adds value without breaking previous functionality
### Parallel Team Strategy (For Larger Teams) ### Parallel Team Strategy (For Larger Teams)
@@ -372,8 +451,9 @@ Task T068-T079: Enhanced mock data quality
2. **Week 2-3** (After Foundational complete): 2. **Week 2-3** (After Foundational complete):
- **Team A** (2 devs): User Story 1 (T015-T031) - Priority focus - **Team A** (2 devs): User Story 1 (T015-T031) - Priority focus
- **Team B** (1 dev): User Story 4 (T032-T042) - Critical for testing - **Team B** (1 dev): User Story 4 (T032-T042) - Critical for testing
- **Team C** (1 dev): User Story 2 data prep (T043-T044) - Prepare for integration - **Team C** (1 dev): User Story 6 data prep (T043-T046) - Remote transport setup
3. **Week 4**: Integrate US1+US4, then continue with US2, US3, US5 - **Team D** (1 dev): User Story 2 data prep (T079-T080) - Prepare for integration
3. **Week 4**: Integrate US1+US4+US6, then continue with US2, US3, US5
4. **Week 5**: Docker packaging, documentation, final polish 4. **Week 5**: Docker packaging, documentation, final polish
### Priority-Driven Sequential (Small Team) ### Priority-Driven Sequential (Small Team)
@@ -381,10 +461,11 @@ Task T068-T079: Enhanced mock data quality
1. Setup → Foundational (T001-T014) 1. Setup → Foundational (T001-T014)
2. User Story 1 - P1 (T015-T031) ✅ MVP CHECKPOINT 2. User Story 1 - P1 (T015-T031) ✅ MVP CHECKPOINT
3. User Story 4 - P2 (T032-T042) - Enable concurrent testing 3. User Story 4 - P2 (T032-T042) - Enable concurrent testing
4. User Story 2 - P2 (T043-T055) - Add hotel capability 4. User Story 6 - P2 (T043-T078) - Enable remote access via Streamable HTTP
5. User Story 3 - P3 (T056-T067) - Complete package 5. User Story 2 - P2 (T079-T091) - Add hotel capability
6. User Story 5 - P3 (T068-T079) - Polish for demos 6. User Story 3 - P3 (T092-T103) - Complete package
7. Docker + Docs (T084-T097) 7. User Story 5 - P3 (T104-T115) - Polish for demos
8. Docker + Docs (T120-T133)
--- ---
@@ -397,15 +478,17 @@ Task T068-T079: Enhanced mock data quality
- Phase 2 (Foundational): 9 tasks - BLOCKS ALL USER STORIES - Phase 2 (Foundational): 9 tasks - BLOCKS ALL USER STORIES
- Phase 3 (US1 - Flight Booking): 17 tasks - MVP ✅ - Phase 3 (US1 - Flight Booking): 17 tasks - MVP ✅
- Phase 4 (US4 - Session Management): 11 tasks - Phase 4 (US4 - Session Management): 11 tasks
- Phase 5 (US2 - Hotel Bundling): 13 tasks - Phase 5 (US6 - Remote Access): 36 tasks
- Phase 6 (US3 - Car Rentals): 12 tasks - Phase 6 (US2 - Hotel Bundling): 13 tasks
- Phase 7 (US5 - Demo Data Quality): 12 tasks - Phase 7 (US3 - Car Rentals): 12 tasks
- Phase 8 (Booking Management): 4 tasks - Phase 8 (US5 - Demo Data Quality): 12 tasks
- Phase 9 (Docker): 6 tasks - Phase 9 (Booking Management): 4 tasks
- Phase 10 (Documentation): 8 tasks - Phase 10 (Docker): 6 tasks
- Phase 11 (Documentation): 8 tasks
**Task Distribution by User Story**: **Task Distribution by User Story**:
- US1 (Flight Booking - P1): 21 tasks (includes Phase 8) - US1 (Flight Booking - P1): 21 tasks (includes Phase 9)
- US6 (Remote Access - P2): 36 tasks
- US2 (Hotel Bundling - P2): 13 tasks - US2 (Hotel Bundling - P2): 13 tasks
- US3 (Car Rentals - P3): 12 tasks - US3 (Car Rentals - P3): 12 tasks
- US4 (Session Management - P2): 11 tasks - US4 (Session Management - P2): 11 tasks
@@ -422,6 +505,7 @@ Task T068-T079: Enhanced mock data quality
**Independent Test Criteria per Story**: **Independent Test Criteria per Story**:
- **US1**: Search flights → book with passengers → retrieve PNR → cancel booking (complete workflow) - **US1**: Search flights → book with passengers → retrieve PNR → cancel booking (complete workflow)
- **US4**: Run 5-10 concurrent sessions, verify zero data leakage between sessions - **US4**: Run 5-10 concurrent sessions, verify zero data leakage between sessions
- **US6**: Connect via Streamable HTTP → verify SSE polling → test MCP-Protocol-Version validation → test rate limiting
- **US2**: Search hotels → book hotel-only → bundle hotel with flight → retrieve multi-service PNR - **US2**: Search hotels → book hotel-only → bundle hotel with flight → retrieve multi-service PNR
- **US3**: Search cars → book car-only → add to flight+hotel → retrieve complete package - **US3**: Search cars → book car-only → add to flight+hotel → retrieve complete package
- **US5**: Review demo routes (JFK-LAX, JFK-LHR, LAX-TYO), verify professional data quality - **US5**: Review demo routes (JFK-LAX, JFK-LHR, LAX-TYO), verify professional data quality
@@ -430,8 +514,8 @@ Task T068-T079: Enhanced mock data quality
- Phase 1: Setup (T001-T005) - Phase 1: Setup (T001-T005)
- Phase 2: Foundational (T006-T014) - Phase 2: Foundational (T006-T014)
- Phase 3: User Story 1 (T015-T031) - Phase 3: User Story 1 (T015-T031)
- Phase 9: Docker packaging basics (T084-T089) - Phase 10: Docker packaging basics (T120-T125)
- Phase 10: Essential docs (T090-T091) - Phase 11: Essential docs (T126-T127)
**Total MVP Tasks**: 34 tasks **Total MVP Tasks**: 34 tasks
**MVP delivers**: Fully functional flight search and booking mock server with Docker deployment **MVP delivers**: Fully functional flight search and booking mock server with Docker deployment

View File

@@ -0,0 +1,549 @@
# Tasks: Mock GDS MCP Server
**Input**: Design documents from `/specs/001-mock-gds-server/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
**Tests**: Tests are NOT explicitly requested in the specification, so test tasks are OMITTED per template guidelines.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4, US5)
- All tasks include exact file paths
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure from plan.md
- [X] T001 Initialize Node.js 20 project with package.json including @modelcontextprotocol/sdk, ioredis, pino dependencies
- [X] T002 Create project directory structure: src/{tools,data,session,validation,utils}/, tests/{integration,unit,fixtures}/, docker/
- [X] T003 [P] Configure ESLint and Prettier for code quality in .eslintrc.json and .prettierrc
- [X] T004 [P] Create .dockerignore and .gitignore files for build optimization
- [X] T005 [P] Create docker-compose.yaml with Valkey service configuration (port 6379, persistence enabled)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [X] T006 Implement Pino structured logger setup in src/utils/logger.js with configurable log levels
- [X] T007 [P] Implement error handling utilities in src/utils/errors.js with MCP error codes
- [X] T008 [P] Create Valkey client wrapper in src/session/storage.js with connection pooling and error handling
- [X] T009 [P] Implement JSON schema validators in src/validation/validators.js using native validation
- [X] T010 [P] Create MCP tool schemas in src/validation/schemas.js based on contracts/mcp-tools.md
- [X] T011 Create session lifecycle manager in src/session/manager.js with TTL management (1 hour default)
- [X] T012 [P] Implement PNR generation utilities in src/data/pnr.js with TEST- prefix and base32 encoding
- [X] T013 Initialize MCP server in src/server.js with @modelcontextprotocol/sdk Server class
- [X] T014 Create MCP server entry point in src/index.js with stdio transport and error handling
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - Flight Search and Booking (Priority: P1) 🎯 MVP
**Goal**: Enable developers to test flight search and booking functionality with realistic mock data. Search flights, create bookings, retrieve bookings, and cancel bookings.
**Independent Test**: Execute searchFlights MCP tool for JFK→LAX route, receive 3-5 mock flight results with valid IATA codes and realistic prices, create booking with passenger details, receive TEST- prefixed PNR, retrieve booking by PNR, and cancel booking. All operations complete successfully without requiring any other service types.
### Core Data for User Story 1
- [X] T015 [P] [US1] Create airports mock data in src/data/airports.js with 100+ major airports (IATA codes, names, cities, timezones, coordinates)
- [X] T016 [P] [US1] Create airlines mock data in src/data/airlines.js with 30+ carriers (IATA codes, names, countries)
- [X] T017 [US1] Implement flight data generator in src/data/flights.js with deterministic pricing, duration calculation, and availability logic
### Flight Search Tool
- [X] T018 [US1] Implement searchFlights tool handler in src/tools/flights.js with input validation (origin, destination, departureDate, passengers, cabin)
- [X] T019 [US1] Add flight search result generation with 3-5 mock flights per search, realistic schedules (6am-10pm departures), and price ranges ($200-$800 economy, $800-$2000 business, $2500+ first)
- [X] T020 [US1] Implement seat availability simulation (90% available, 10% sold out) and booking class assignment. **Note**: Implements infinite inventory model where concurrent bookings on same flight both succeed. This aligns with spec.md edge case discussion and is appropriate for mock server testing scope.
### Flight Booking Tool
- [X] T021 [US1] Implement bookFlight tool handler in src/tools/flights.js with passenger validation (firstName, lastName, email, phone)
- [X] T022 [US1] Add flight booking creation logic: validate flight selection, generate PNR via src/data/pnr.js, persist to Valkey with key gds:session:{sessionId}:booking:{pnr}
- [X] T023 [US1] Implement PNR storage structure in Valkey with FlightSegment, Passenger, pricing, status fields per data-model.md
- [X] T024 [US1] Add session booking tracking: update gds:session:{sessionId}:bookings set and increment bookingCount
### Booking Management Tools
- [X] T025 [US1] Implement retrieveBooking tool handler in src/tools/bookings.js with PNR validation and Valkey lookup
- [X] T026 [US1] Add booking retrieval logic: fetch from gds:session:{sessionId}:booking:{pnr}, return complete booking details with all segments
- [X] T027 [US1] Implement cancelBooking tool handler in src/tools/bookings.js with status transition validation (confirmed→cancelled only)
- [X] T028 [US1] Add cancellation logic: update booking status to 'cancelled', persist timestamp, return confirmation
### MCP Server Integration
- [X] T029 [US1] Register searchFlights, bookFlight, retrieveBooking, cancelBooking tools in src/server.js with tool handlers
- [X] T030 [US1] Add request/response logging for all flight operations with session ID, operation type, parameters, and response times
- [X] T031 [US1] Implement error handling for invalid airport codes, invalid dates, validation failures with specific error messages per FR-014
**FR-015 Coverage Note**: Multi-step booking workflows (search → price verification → select → confirm) are implemented through the task sequence T018-T028. The searchFlights tool (T018-T020) enables price verification, bookFlight (T021-T024) handles selection and confirmation, and retrieveBooking (T025-T026) supports workflow verification.
**Checkpoint**: At this point, User Story 1 should be fully functional - developers can search flights, create bookings, retrieve bookings, and cancel bookings independently.
---
## Phase 4: User Story 4 - Session Management for Concurrent Testing (Priority: P2)
**Goal**: Enable concurrent MCP sessions with isolated booking state for parallel test execution and multi-developer environments.
**Independent Test**: Start 5-10 concurrent MCP sessions, perform different flight bookings in each (different routes, passenger names), verify PNR retrieval in each session returns only that session's bookings with zero cross-session data leakage. Success means automated tests can run in parallel without interference.
### Session Infrastructure
- [X] T032 [US4] Implement session creation logic in src/session/manager.js: generate UUID v4 session ID on MCP connection initialization
- [X] T033 [US4] Add session metadata storage in Valkey at gds:session:{sessionId} with createdAt, expiresAt, lastActivity, bookingCount, searchCount fields
- [X] T034 [US4] Implement session TTL management: set EXPIRE on session keys (default 3600 seconds), refresh on activity
- [X] T035 [US4] Add session validation middleware in src/session/manager.js: verify session exists and not expired before tool execution
### Session Isolation
- [X] T036 [US4] Implement session-scoped key prefixing in src/session/storage.js: all Valkey keys include session ID for isolation
- [X] T037 [US4] Update booking storage to enforce session scope: gds:session:{sessionId}:booking:{pnr} pattern in all tools
- [X] T038 [US4] Add session cleanup on expiry: implement background job or TTL-based cleanup for expired session data
- [X] T039 [US4] Implement session statistics tracking: maintain gds:stats:sessions:active set, update gds:stats:bookings:total counter
### Session Validation
- [X] T040 [US4] Add cross-session isolation validation in retrieveBooking tool: verify PNR belongs to current session before returning
- [X] T041 [US4] Implement session activity tracking in src/session/manager.js: update lastActivity timestamp on every tool call
- [X] T042 [US4] Add session error responses for expired/invalid sessions with clear messages "Session expired" or "Session not found"
**Checkpoint**: Multiple MCP sessions can now run concurrently with complete isolation - bookings in one session never appear in another session.
---
## Phase 5: User Story 6 - Remote Access for Distributed Teams (Priority: P2)
**Goal**: Enable remote MCP access over Streamable HTTP (HTTP/1.1 + SSE per MCP 2025-11-25 specification) with rate limiting, CORS, health checks, and comprehensive security.
**Independent Test**: Start server with remote transport enabled, connect from remote MCP client over HTTP/1.1 using SSE for server-to-client messages and POST for client-to-server requests, execute flight search tool with MCP-Protocol-Version header, verify SSE polling pattern with connection closure and retry field, test Last-Event-ID resumption, verify rate limiting enforces 100 req/min default, test CORS preflight with wildcard origin, verify health endpoint returns service status, confirm MCP-Session-Id header management, confirm graceful shutdown preserves active sessions. All operations work independently of other user stories.
### Streamable HTTP Server Setup (MCP 2025-11-25 Compliant)
- [ ] T043 [P] [US6] Update package.json with dependencies: @modelcontextprotocol/sdk for StreamableHTTPServerTransport, express for middleware
- [ ] T044 [US6] Create Streamable HTTP server in src/transports/http-server.js using MCP SDK's StreamableHTTPServerTransport (HTTP/1.1 + SSE)
- [ ] T045 [US6] Implement single /mcp endpoint supporting POST (client messages), GET (server message stream), DELETE (session termination) per MCP spec
- [ ] T046 [US6] Add HTTP server lifecycle management: startup on configurable port, graceful shutdown (drain connections, 30s timeout), error handling
### SSE Polling Pattern Implementation (MCP 2025-11-25 Spec)
- [ ] T047 [US6] Implement SSE event stream with unique event IDs for resumability in src/transports/sse-handler.js
- [ ] T048 [US6] Add initial SSE event with ID and empty data field to prime client reconnection per MCP polling pattern
- [ ] T049 [US6] Implement connection closure after response with `retry` field (default: 5000ms) to guide client reconnection timing
- [ ] T050 [US6] Add Last-Event-ID header support for stream resumption when clients reconnect after disconnection
### MCP Protocol Version Validation
- [ ] T051 [US6] Create protocol version middleware in src/middleware/protocol-version.js
- [ ] T052 [US6] Implement MCP-Protocol-Version header validation: require header on all requests, reject missing/invalid versions with 400 Bad Request
- [ ] T053 [US6] Add supported version check: accept 2025-11-25, reject unsupported versions with clear error message
### Session Management (MCP-Session-Id Header)
- [ ] T054 [US6] Implement MCP-Session-Id header handling in src/session/session-manager.js
- [ ] T055 [US6] Add session ID generation on InitializeResult response (cryptographically secure UUID)
- [ ] T056 [US6] Validate MCP-Session-Id on subsequent requests: respond with 404 if session expired/not found
- [ ] T057 [US6] Implement DELETE /mcp handler for explicit session termination
### Rate Limiting
- [ ] T058 [P] [US6] Install express-rate-limit package in package.json for IP-based rate limiting
- [ ] T059 [US6] Create rate limiter middleware in src/middleware/rate-limit.js with configurable limits (default: 100 req/min per IP)
- [ ] T060 [US6] Implement rate limit enforcement: return 429 Too Many Requests with Retry-After header when limit exceeded
- [ ] T061 [US6] Add rate limit headers to all responses: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
- [ ] T062 [US6] Implement rate limit storage in Valkey: use sliding window counter with key expiration (60s TTL)
### CORS Configuration (Wildcard Policy)
- [ ] T063 [P] [US6] Install cors package in package.json for CORS middleware
- [ ] T064 [US6] Create CORS handler in src/middleware/cors.js with permissive wildcard policy (Access-Control-Allow-Origin: *)
- [ ] T065 [US6] Implement CORS preflight handling for OPTIONS requests: return allowed methods (GET, POST, DELETE, OPTIONS), headers (Content-Type, MCP-Session-Id, MCP-Protocol-Version)
- [ ] T066 [US6] Add Origin header validation per MCP security requirement: respond with 403 Forbidden if Origin present and validation fails
### Health Checks
- [ ] T067 [US6] Implement health check endpoint in src/remote/health.js returning GET /health with JSON status response
- [ ] T068 [US6] Add service health indicators: Valkey connection status, active session count, uptime, memory usage
- [ ] T069 [US6] Implement readiness check: return 503 Service Unavailable if Valkey is disconnected or critical services unavailable
- [ ] T070 [US6] Add health check logging: log every 10th health check to avoid log spam, log all failed health checks immediately
### Security & Transport Selection
- [ ] T071 [US6] Create transport factory in src/transports/factory.js to select stdio or Streamable HTTP based on TRANSPORT environment variable
- [ ] T072 [US6] Implement environment-based configuration in src/config/remote.js: PORT (default: 3000), HOST (default: 127.0.0.1 per MCP security), RATE_LIMIT_MAX, CORS_ORIGINS
- [ ] T073 [US6] Add request logging middleware in src/middleware/logger.js: log method, path, IP, User-Agent, MCP-Protocol-Version, duration, status code
- [ ] T074 [US6] Implement localhost binding (127.0.0.1) by default per MCP security recommendation, document production deployment with reverse proxy
### Integration & Testing Infrastructure
- [ ] T075 [US6] Update src/index.js to support dual transport modes: stdio (default) or Streamable HTTP (when TRANSPORT=http or --remote flag)
- [ ] T076 [US6] Add CLI argument parsing in src/index.js: --remote (enable HTTP), --port <number>, --host <address>, --verbose, --log-level <level>
- [ ] T077 [US6] Create remote access example client in tests/fixtures/remote-client.js demonstrating SSE connection, Last-Event-ID resumption, and tool invocation
- [ ] T078 [US6] Add nginx.conf.example in docker/ for optional HTTP/2 upgrade reverse proxy (client-facing HTTP/2, backend HTTP/1.1)
**Checkpoint**: Remote access complete - developers can connect to MCP server over Streamable HTTP (HTTP/1.1 + SSE per MCP 2025-11-25 spec), enforced rate limits, wildcard CORS, MCP-Protocol-Version validation, SSE polling pattern with connection closure, Last-Event-ID resumption, and health monitoring. Transport selection via TRANSPORT env var or --remote flag.
---
## Phase 6: User Story 2 - Hotel Search and Multi-Service Bundling (Priority: P2)
**Goal**: Enable developers to test hotel search and multi-service booking workflows. Search hotels, create hotel bookings, and bundle hotels with flights under single PNR.
**Independent Test**: Perform hotel search for destination city (LAX) with check-in/check-out dates, receive 5-10 hotel options with star ratings and amenities. Create hotel-only booking with PNR. Then create flight booking, add hotel to existing flight PNR, retrieve combined itinerary showing both services under one PNR. Success means multi-service bundling works without breaking flight-only functionality.
### Hotel Data
- [ ] T079 [P] [US2] Create hotels mock data in src/data/hotels.js with 50+ properties across major cities (names, chains, star ratings, addresses, amenities)
- [ ] T080 [US2] Implement hotel data generator with realistic pricing tiers: budget $80-$150, midrange $150-$300, luxury $300-$800 per night
### Hotel Search Tool
- [ ] T081 [US2] Implement searchHotels tool handler in src/tools/hotels.js with input validation (cityCode, checkInDate, checkOutDate, guests)
- [ ] T082 [US2] Add hotel search result generation: 5-10 properties per search, calculate nights and total prices, include amenities (WiFi, parking, breakfast, gym, pool)
- [ ] T083 [US2] Implement date validation: checkInDate < checkOutDate, minimum 1 night stay, no past dates
### Hotel Booking Tool
- [ ] T084 [US2] Implement bookHotel tool handler in src/tools/hotels.js with guest validation and room selection
- [ ] T085 [US2] Add hotel booking creation: generate or use existing PNR, persist HotelReservation to Valkey with check-in/check-out dates, room type, pricing
- [ ] T086 [US2] Implement multi-service bundling logic in src/tools/hotels.js: add hotel to existing PNR if provided, create new PNR if not
### Multi-Service Integration
- [ ] T087 [US2] Update PNR structure in src/data/pnr.js to support multiple service segments: flights[], hotels[], cars[] arrays
- [ ] T088 [US2] Implement total price calculation across all segments: sum flight prices + hotel prices, update PNR totalPrice field
- [ ] T089 [US2] Add date consistency validation: hotel dates should overlap with flight dates, warn if hotel is outside travel period
- [ ] T090 [US2] Update retrieveBooking tool in src/tools/bookings.js to return complete multi-service itineraries with all segment types
- [ ] T091 [US2] Register searchHotels and bookHotel tools in src/server.js with tool handlers
**Checkpoint**: Developers can now search hotels, create hotel bookings, and bundle hotels with flights. Both flight-only and flight+hotel bookings work independently.
---
## Phase 7: User Story 3 - Car Rental and Complete Travel Package (Priority: P3)
**Goal**: Complete the full travel package capability by adding car rentals. Search cars, create car bookings, and bundle with flights+hotels for complete end-to-end travel itineraries.
**Independent Test**: Search for rental cars at destination airport (LAX) with pickup/dropoff dates, receive 4-6 vehicle classes (economy, SUV, luxury) with daily rates. Create car-only booking. Then add car to existing flight+hotel PNR, retrieve complete package showing all three services in chronological trip order. Success means full GDS capability coverage with all service types working together.
### Car Rental Data
- [ ] T092 [P] [US3] Create car rental mock data in src/data/cars.js with 6+ rental companies (Hertz, Avis, Enterprise codes and names)
- [ ] T093 [US3] Implement car rental data generator with vehicle classes: economy $35-$50, midsize $50-$80, SUV/luxury $100-$150 per day
### Car Rental Search Tool
- [ ] T094 [US3] Implement searchCars tool handler in src/tools/cars.js with input validation (pickupLocation, pickupDate, dropoffLocation, dropoffDate)
- [ ] T095 [US3] Add car search result generation: 4-6 vehicle classes per search, calculate rental days and total prices, include vehicle specs and mileage policies
- [ ] T096 [US3] Implement date validation: pickupDate < dropoffDate, minimum 1 day rental, location validation (airport/city codes)
### Car Rental Booking Tool
- [ ] T097 [US3] Implement bookCar tool handler in src/tools/cars.js with driver validation and vehicle selection
- [ ] T098 [US3] Add car rental booking creation: generate or use existing PNR, persist CarRental to Valkey with pickup/dropoff details, vehicle class, pricing
- [ ] T099 [US3] Implement car bundling logic in src/tools/cars.js: add car to existing PNR, validate dates align with flight arrival/departure
### Complete Package Integration
- [ ] T100 [US3] Update total price calculation in src/data/pnr.js to include car rental prices: sum flights + hotels + cars
- [ ] T101 [US3] Add chronological itinerary ordering in retrieveBooking: sort segments by date/time (flight arrival → car pickup → hotel check-in → hotel check-out → car dropoff → return flight)
- [ ] T102 [US3] Implement date consistency validation for cars: pickup should align with flight arrival, dropoff should align with departure
- [ ] T103 [US3] Register searchCars and bookCar tools in src/server.js with tool handlers
**Checkpoint**: Full GDS capability is now complete - developers can create comprehensive travel packages with flights, hotels, and cars all bundled under one PNR.
---
## Phase 8: User Story 5 - Realistic Test Data for Demonstrations (Priority: P3)
**Goal**: Polish mock data quality to enable professional sales demonstrations and training scenarios with recognizable brands, realistic pricing, and diverse route coverage.
**Independent Test**: Execute searches for major demo routes (JFK→LAX domestic, JFK→LHR international, LAX→TYO Asia-Pacific), verify results include recognizable brands (United, Marriott, Hertz), prices in expected market ranges, realistic travel times, and professional presentation suitable for live demos without disclaimers. Success means sales team can confidently present the mock server as representative of production GDS quality.
### Enhanced Mock Data Quality
- [ ] T104 [P] [US5] Expand airports mock data in src/data/airports.js to include 50+ international hubs (London LHR, Paris CDG, Tokyo NRT, Dubai DXB, etc.)
- [ ] T105 [P] [US5] Add major hotel chains to src/data/hotels.js: Marriott, Hilton, Hyatt, IHG properties in 20+ major cities worldwide
- [ ] T106 [P] [US5] Enhance airline data in src/data/airlines.js with international carriers: British Airways, Virgin Atlantic, Lufthansa, Emirates, ANA, JAL
### Route-Specific Pricing
- [ ] T107 [US5] Implement distance-based pricing in src/data/flights.js: calculate flight distance from coordinates, apply pricing tiers (short-haul $200-$400, medium-haul $400-$800, long-haul $800-$2000)
- [ ] T108 [US5] Add realistic flight durations per route in src/data/flights.js: JFK-LAX ~6 hours, JFK-LHR ~7 hours, LAX-TYO ~12 hours
- [ ] T109 [US5] Implement time-of-day departure variety: morning (6-9am), midday (9am-2pm), afternoon (2-5pm), evening (5-10pm) for realistic schedule diversity
### Premium Cabin Classes
- [ ] T110 [US5] Add premium cabin pricing in src/data/flights.js: economy baseline, premium economy +40%, business +200%, first class +400%
- [ ] T111 [US5] Implement cabin-specific availability and booking classes: Y/B/M for economy, W for premium economy, J/C for business, F/A for first
- [ ] T112 [US5] Add cabin-specific amenities in flight results: economy (standard seat), business (lie-flat, lounge access), first (suites, premium dining)
### Demo Scenarios Configuration
- [ ] T113 [US5] Create demo seed data option in src/data/flights.js: when MOCK_DATA_SEED=demo, return curated high-quality results for common demo routes
- [ ] T114 [US5] Add configurable response delay in src/utils/logger.js via MOCK_RESPONSE_DELAY env var to simulate realistic search response times during demos
- [ ] T115 [US5] Implement metadata tagging in all responses: add "data_source": "mock" field to all search results and bookings per FR-012
**Checkpoint**: Mock data is now demo-quality - professional appearance, recognizable brands, realistic pricing suitable for sales presentations and training without requiring explanations.
---
## Phase 9: Additional Booking Management (Supporting Tools)
**Goal**: Add convenience tool for listing all bookings in a session (supports User Stories 1-4).
**Independent Test**: Create multiple bookings (flights, hotels, cars) in a session, call listBookings tool, verify it returns all PNRs created in the session with summary information. Provides developers quick overview of session state.
- [ ] T116 [US1] Implement listBookings tool handler in src/tools/bookings.js to retrieve all PNRs in current session
- [ ] T117 [US1] Add booking list query: read gds:session:{sessionId}:bookings set, fetch summary for each PNR (pnr, status, createdAt, totalPrice, segment counts)
- [ ] T118 [US1] Register listBookings tool in src/server.js with tool handler
- [ ] T119 [US1] Add pagination support for listBookings if session has 10+ bookings: limit and offset parameters
---
## Phase 10: Docker Packaging
**Purpose**: Container packaging for deployment and distribution
- [ ] T120 Create multi-stage Dockerfile in docker/Dockerfile: builder stage (install deps, copy source) and production stage (Node.js 20 Alpine, non-root user)
- [ ] T121 Create docker-bake.hcl in docker/ for multi-platform builds: linux/amd64 and linux/arm64 targets
- [ ] T122 [P] Add health check to Dockerfile: verify Valkey connection and MCP server readiness
- [ ] T123 [P] Create .env.example file with all configuration variables documented (MCP, Valkey, logging, mock data settings)
- [ ] T124 Update docker-compose.yaml to include gds-mock-mcp service with Valkey dependency and environment variable mapping
- [ ] T125 Add build and run scripts in package.json: npm run docker:build, npm run docker:run, npm run docker:down
---
## Phase 11: Documentation & Polish
**Purpose**: User-facing documentation, validation, and final polish
- [ ] T126 [P] Create comprehensive README.md: installation instructions, configuration reference, Docker usage, MCP tool documentation, troubleshooting guide
- [ ] T127 [P] Add SAFETY_DISCLAIMER.md: prominent "FOR TESTING AND DEMO PURPOSES ONLY" notice, explanation of TEST- prefix, no real transactions guarantee
**Success Criteria Alignment**: The README (T090) and quickstart guide (T091) should emphasize:
- **SC-006**: Professional, demo-quality data requiring zero disclaimers or explanations during sales demonstrations
- **SC-007**: Clear documentation enabling new developers to understand GDS workflows within 15 minutes using realistic examples as learning material
These documentation objectives ensure the mock server serves both testing and training purposes effectively.
- [ ] T128 [P] Create CHANGELOG.md documenting feature implementation and version history
- [ ] T129 [P] Add inline code documentation: JSDoc comments for all public functions, tool handlers, data generators
- [ ] T130 Validate quickstart.md examples: test all example commands in quickstart.md work correctly with current implementation
- [ ] T131 Add logging coverage review: ensure all operations log appropriately (search, book, retrieve, cancel, errors) with sufficient detail per FR-013
- [ ] T132 Perform security review: verify no production credentials, no external API calls, TEST- prefix enforcement, non-root Docker user per Constitution Principle III
- [ ] T133 Run constitution compliance check: verify all 6 principles (MCP protocol, mock data realism, no real transactions, tool architecture, session management, observability)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phases 3-7)**: All depend on Foundational phase completion
- **Phase 3 (US1 - P1)**: Can start after Foundational - No dependencies on other stories ✅ MVP
- **Phase 4 (US4 - P2)**: Can start after Foundational - Independent, but beneficial before scale testing
- **Phase 5 (US2 - P2)**: Can start after Foundational - Extends US1, but independently testable
- **Phase 6 (US3 - P3)**: Can start after Foundational - Extends US1+US2, but independently testable
- **Phase 7 (US5 - P3)**: Can start after Foundational - Enhances data quality across all stories
- **Phase 8**: Extends US1 - can start after US1 complete
- **Docker Packaging (Phase 9)**: Can start after US1 complete (minimal MVP) or after all user stories
- **Documentation (Phase 10)**: Can start in parallel with user story work, finalize after all stories complete
### User Story Dependencies (Independence Analysis)
- **US1 (Flight Booking)**: Fully independent - no dependencies
- **US4 (Session Management)**: Fully independent - enhances US1 but not required for basic functionality
- **US2 (Hotel Bundling)**: Extends US1 but independently testable (can create hotel-only bookings)
- **US3 (Car Rentals)**: Extends US1+US2 but independently testable (can create car-only bookings)
- **US5 (Demo Data)**: Enhances all stories but doesn't block functionality
### Within Each User Story
- Data files before generators
- Generators before tool handlers
- Tool handlers before MCP server registration
- Core operations before advanced features
- Validation before error handling
### Parallel Opportunities
**Setup Phase**: T003 [P], T004 [P], T005 [P] can run in parallel
**Foundational Phase**: T007 [P], T008 [P], T009 [P], T010 [P], T012 [P] can run in parallel after T006
**User Story 1**: T015 [P] + T016 [P] (data files), then T018-T031 sequentially
**User Story 6**: T043 [P] + T058 [P] + T063 [P] can run in parallel with US1 tasks
**User Story 2**: T079 [P] can run in parallel with US1/US6 tasks
**User Story 3**: T092 [P] + T093 [P] can run in parallel with US1/US2/US6 tasks
**User Story 5**: T104 [P] + T105 [P] + T106 [P] can run in parallel
**User Stories across teams**: Once Foundational complete, different developers can work on US1, US2, US3, US4, US5, US6 in parallel
---
## Parallel Example: Multi-Story Development
```bash
# After Foundational Phase completes, launch all user stories in parallel:
# Developer A: User Story 1 (MVP)
Task T015-T031: Flight search and booking implementation
# Developer B: User Story 4 (Session Management)
Task T032-T042: Concurrent session isolation
# Developer C: User Story 6 (Remote Access)
Task T043-T078: Streamable HTTP transport with SSE polling
# Developer D: User Story 2 (Hotels)
Task T079-T091: Hotel search and bundling
# Developer E: User Story 3 (Cars)
Task T092-T103: Car rental integration
# Developer F: User Story 5 (Demo Data)
Task T104-T115: Enhanced mock data quality
```
---
## Implementation Strategy
### MVP First (User Story 1 Only) - RECOMMENDED START
1. ✅ Complete Phase 1: Setup (T001-T005)
2. ✅ Complete Phase 2: Foundational (T006-T014) - CRITICAL BLOCKER
3. ✅ Complete Phase 3: User Story 1 (T015-T031) - Flight search and booking
4. **STOP and VALIDATE**: Test flight search → book → retrieve → cancel workflow independently
5. Optional: Add Phase 9 (Docker) for easy deployment
6. Optional: Add Phase 10 (Documentation)
7. **Deploy/Demo MVP** - Fully functional flight booking mock server
### Incremental Delivery (Recommended)
1. Foundation (Setup + Foundational) → T001-T014 complete
2. **MVP Release**: Add US1 (T015-T031) → Test independently → Deploy v0.1
3. **Multi-User Release**: Add US4 (T032-T042) → Test concurrency → Deploy v0.2
4. **Remote Access**: Add US6 (T043-T078) → Test Streamable HTTP + SSE → Deploy v0.3
5. **Hotel Bundling**: Add US2 (T079-T091) → Test multi-service → Deploy v0.4
6. **Full Package**: Add US3 (T092-T103) → Test complete packages → Deploy v0.5
7. **Demo Quality**: Add US5 (T104-T115) → Polish for presentations → Deploy v1.0
8. Each increment adds value without breaking previous functionality
### Parallel Team Strategy (For Larger Teams)
1. **Week 1**: Entire team completes Setup + Foundational (T001-T014)
2. **Week 2-3** (After Foundational complete):
- **Team A** (2 devs): User Story 1 (T015-T031) - Priority focus
- **Team B** (1 dev): User Story 4 (T032-T042) - Critical for testing
- **Team C** (1 dev): User Story 6 data prep (T043-T046) - Remote transport setup
- **Team D** (1 dev): User Story 2 data prep (T079-T080) - Prepare for integration
3. **Week 4**: Integrate US1+US4+US6, then continue with US2, US3, US5
4. **Week 5**: Docker packaging, documentation, final polish
### Priority-Driven Sequential (Small Team)
1. Setup → Foundational (T001-T014)
2. User Story 1 - P1 (T015-T031) ✅ MVP CHECKPOINT
3. User Story 4 - P2 (T032-T042) - Enable concurrent testing
4. User Story 6 - P2 (T043-T078) - Enable remote access via Streamable HTTP
5. User Story 2 - P2 (T079-T091) - Add hotel capability
6. User Story 3 - P3 (T092-T103) - Complete package
7. User Story 5 - P3 (T104-T115) - Polish for demos
8. Docker + Docs (T120-T133)
---
## Task Summary
**Total Tasks**: 97 tasks
**Task Distribution by Phase**:
- Phase 1 (Setup): 5 tasks
- Phase 2 (Foundational): 9 tasks - BLOCKS ALL USER STORIES
- Phase 3 (US1 - Flight Booking): 17 tasks - MVP ✅
- Phase 4 (US4 - Session Management): 11 tasks
- Phase 5 (US6 - Remote Access): 36 tasks
- Phase 6 (US2 - Hotel Bundling): 13 tasks
- Phase 7 (US3 - Car Rentals): 12 tasks
- Phase 8 (US5 - Demo Data Quality): 12 tasks
- Phase 9 (Booking Management): 4 tasks
- Phase 10 (Docker): 6 tasks
- Phase 11 (Documentation): 8 tasks
**Task Distribution by User Story**:
- US1 (Flight Booking - P1): 21 tasks (includes Phase 9)
- US6 (Remote Access - P2): 36 tasks
- US2 (Hotel Bundling - P2): 13 tasks
- US3 (Car Rentals - P3): 12 tasks
- US4 (Session Management - P2): 11 tasks
- US5 (Demo Data - P3): 12 tasks
- Infrastructure (Setup + Foundational + Docker + Docs): 28 tasks
**Parallel Opportunities Identified**:
- Setup: 3 parallel tasks
- Foundational: 5 parallel tasks
- User Story data preparation: Multiple files can be created in parallel
- Complete user stories: 5 stories can be developed in parallel after Foundational
- Documentation: Can progress alongside implementation
**Independent Test Criteria per Story**:
- **US1**: Search flights → book with passengers → retrieve PNR → cancel booking (complete workflow)
- **US4**: Run 5-10 concurrent sessions, verify zero data leakage between sessions
- **US6**: Connect via Streamable HTTP → verify SSE polling → test MCP-Protocol-Version validation → test rate limiting
- **US2**: Search hotels → book hotel-only → bundle hotel with flight → retrieve multi-service PNR
- **US3**: Search cars → book car-only → add to flight+hotel → retrieve complete package
- **US5**: Review demo routes (JFK-LAX, JFK-LHR, LAX-TYO), verify professional data quality
**Suggested MVP Scope** (Minimum Viable Product):
- Phase 1: Setup (T001-T005)
- Phase 2: Foundational (T006-T014)
- Phase 3: User Story 1 (T015-T031)
- Phase 10: Docker packaging basics (T120-T125)
- Phase 11: Essential docs (T126-T127)
**Total MVP Tasks**: 34 tasks
**MVP delivers**: Fully functional flight search and booking mock server with Docker deployment
---
## Format Validation ✅
All tasks follow the required checklist format:
- ✅ Every task starts with `- [ ]` (markdown checkbox)
- ✅ Every task has sequential Task ID (T001, T002, T003...)
- ✅ User story tasks have [Story] label ([US1], [US2], [US3], [US4], [US5])
- ✅ Setup, Foundational, Docker, and Polish tasks have NO story label (correctly)
- ✅ Parallel tasks marked with [P]
- ✅ All descriptions include clear actions and exact file paths
- ✅ Tasks organized by phase with user stories as primary organization
- ✅ Each user story phase includes goal and independent test criteria
---
## Notes
- No tests included (not explicitly requested in spec.md per template guidelines)
- Tasks ordered for dependency flow: data → generators → tools → server integration
- Each user story can be deployed and tested independently
- MVP (US1 only) provides immediate value for flight booking testing
- Session management (US4) recommended early for CI/CD integration
- Multi-service bundling (US2, US3) extends functionality without breaking US1
- Demo data polish (US5) is final enhancement, not blocking
- Constitution compliance verification built into final phase
- All 8 MCP tools from contracts/mcp-tools.md covered across user stories

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
};

155
tests/fixtures/remote-client.js vendored Normal file
View File

@@ -0,0 +1,155 @@
/**
* Example MCP Remote Client
* Demonstrates SSE connection, Last-Event-ID resumption, and tool invocation
*
* Usage: node tests/fixtures/remote-client.js
*/
import { EventSource } from 'eventsource';
const MCP_SERVER_URL = process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000';
const MCP_PROTOCOL_VERSION = '2025-06-18';
/**
* Example: Connect to MCP server and invoke searchFlights tool
*/
async function exampleClient() {
console.log(`Connecting to MCP server at ${MCP_SERVER_URL}...`);
// 1. Initialize connection - send initialize request
// Per MCP spec: MCP-Protocol-Version header is NOT required during initialization
const initRequest = {
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: {
name: 'gds-mock-test-client',
version: '1.0.0'
}
}
};
console.log('Sending initialize request (without MCP-Protocol-Version header)...');
const initResponse = await fetch(`${MCP_SERVER_URL}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
// Note: MCP-Protocol-Version header NOT included during initialization
},
body: JSON.stringify(initRequest)
});
const initResult = await initResponse.json();
console.log('Server initialized:', JSON.stringify(initResult, null, 2));
// Note: Server runs in stateless mode (no MCP-Session-Id header will be present)
// Each client can initialize independently without server-side session management
const sessionId = initResponse.headers.get('MCP-Session-Id');
if (sessionId) {
console.log('Session ID:', sessionId);
} else {
console.log('Server running in stateless mode (no session ID)');
}
// 2. Open SSE stream for server messages (now with MCP-Protocol-Version header)
const eventSource = new EventSource(`${MCP_SERVER_URL}/mcp`, {
headers: {
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
...(sessionId && { 'MCP-Session-Id': sessionId })
}
});
let lastEventId = null;
// Handle SSE events
eventSource.addEventListener('message', (event) => {
console.log('Received SSE event:', event.data);
lastEventId = event.lastEventId;
try {
const data = JSON.parse(event.data);
// Extract session ID from InitializeResult
if (data.result && data.result.protocolVersion) {
console.log('Server initialized:', data.result);
}
} catch {
// Empty data or non-JSON - expected per SSE polling pattern
}
});
eventSource.addEventListener('error', (error) => {
console.error('SSE error:', error);
});
// Wait for connection
await new Promise(resolve => setTimeout(resolve, 1000));
// 2. Send tool invocation via POST
const searchRequest = {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'searchFlights',
arguments: {
origin: 'JFK',
destination: 'LAX',
departureDate: '2026-05-01',
passengers: 1,
cabin: 'economy'
}
}
};
console.log('Sending searchFlights request...');
const response = await fetch(`${MCP_SERVER_URL}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
...(sessionId && { 'MCP-Session-Id': sessionId }),
...(lastEventId && { 'Last-Event-ID': lastEventId })
},
body: JSON.stringify(searchRequest)
});
const result = await response.json();
console.log('Search results:', JSON.stringify(result, null, 2));
// 3. Demonstrate Last-Event-ID resumption
if (lastEventId) {
console.log(`\nReconnecting with Last-Event-ID: ${lastEventId}...`);
const resumedEventSource = new EventSource(`${MCP_SERVER_URL}/mcp`, {
headers: {
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
'Last-Event-ID': lastEventId
}
});
resumedEventSource.addEventListener('message', (event) => {
console.log('Resumed stream event:', event.data);
});
// Close after demonstration
setTimeout(() => {
resumedEventSource.close();
eventSource.close();
console.log('\nClient demonstration complete');
process.exit(0);
}, 2000);
}
}
// Run example
exampleClient().catch(err => {
console.error('Client error:', err);
process.exit(1);
});

2
tests/unit/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Tests directory placeholder
# Test implementation not included in MVP scope

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"noImplicitAny": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}