UNPKG

@siva-sub/mcp-public-transport

Version:

A Model Context Protocol server for Singapore transport data with real-time information and routing

907 lines (906 loc) 40 kB
import { z } from 'zod'; import { BaseTool } from '../base.js'; import { PolylineService } from '../../services/polylines.js'; import { InstructionParserService } from '../../services/instructionParser.js'; import { MRTExitService } from '../../services/mrtExits.js'; import { CacheService } from '../../services/cache.js'; import { validateInput } from '../../utils/validation.js'; import { logger } from '../../utils/logger.js'; // Enhanced input schema for comprehensive journey planning const ComprehensiveJourneyInputSchema = z.object({ fromLocation: z.union([ z.string().min(1), z.object({ postalCode: z.string().regex(/^\d{6}$/, 'Postal code must be 6 digits'), }), z.object({ latitude: z.number().min(1.0).max(1.5), longitude: z.number().min(103.0).max(104.5), name: z.string().optional(), }), ]), toLocation: z.union([ z.string().min(1), z.object({ postalCode: z.string().regex(/^\d{6}$/, 'Postal code must be 6 digits'), }), z.object({ latitude: z.number().min(1.0).max(1.5), longitude: z.number().min(103.0).max(104.5), name: z.string().optional(), }), ]), mode: z.enum(['PUBLIC_TRANSPORT', 'WALK', 'DRIVE', 'CYCLE', 'AUTO']).default('AUTO'), departureTime: z.string().optional(), arrivalTime: z.string().optional(), preferences: z.object({ maxWalkDistance: z.number().min(100).max(2000).default(1000), fastest: z.boolean().default(true), cheapest: z.boolean().default(false), minimizeTransfers: z.boolean().default(true), accessibilityRequired: z.boolean().default(false), weatherAware: z.boolean().default(true), }).default({}), outputOptions: z.object({ includeInstructions: z.boolean().default(true), includePolylines: z.boolean().default(true), includeAlternatives: z.boolean().default(true), includeContext: z.boolean().default(true), instructionFormat: z.enum(['detailed', 'simple', 'navigation']).default('detailed'), coordinateFormat: z.enum(['WGS84', 'decimal']).default('WGS84'), }).default({}), maxAlternatives: z.number().min(1).max(5).default(3), }); export class ComprehensiveJourneyTool extends BaseTool { oneMapService; ltaService; weatherService; polylineService; instructionParser; mrtExitService; constructor(oneMapService, ltaService, weatherService) { super(); this.oneMapService = oneMapService; this.ltaService = ltaService; this.weatherService = weatherService; this.polylineService = new PolylineService(); this.instructionParser = new InstructionParserService(); this.mrtExitService = new MRTExitService(new CacheService()); } getDefinitions() { return [ { name: 'plan_comprehensive_journey', description: 'Plan a comprehensive journey with step-by-step instructions, route visualization, and contextual information', inputSchema: this.createSchema({ fromLocation: { oneOf: [ { type: 'string', description: 'Starting location as address or landmark (e.g., "Marina Bay Sands", "Orchard MRT")', }, { type: 'object', properties: { postalCode: { type: 'string', pattern: '^\\d{6}$', description: 'Singapore postal code (6 digits)', }, }, required: ['postalCode'], description: 'Starting location as postal code', }, { type: 'object', properties: { latitude: { type: 'number', minimum: 1.0, maximum: 1.5, description: 'Latitude coordinate', }, longitude: { type: 'number', minimum: 103.0, maximum: 104.5, description: 'Longitude coordinate', }, name: { type: 'string', description: 'Optional location name', }, }, required: ['latitude', 'longitude'], description: 'Starting location as coordinates', }, ], description: 'Starting location - provide as address, postal code, or coordinates', }, toLocation: { oneOf: [ { type: 'string', description: 'Destination location as address or landmark (e.g., "Changi Airport", "NUS")', }, { type: 'object', properties: { postalCode: { type: 'string', pattern: '^\\d{6}$', description: 'Singapore postal code (6 digits)', }, }, required: ['postalCode'], description: 'Destination location as postal code', }, { type: 'object', properties: { latitude: { type: 'number', minimum: 1.0, maximum: 1.5, description: 'Latitude coordinate', }, longitude: { type: 'number', minimum: 103.0, maximum: 104.5, description: 'Longitude coordinate', }, name: { type: 'string', description: 'Optional location name', }, }, required: ['latitude', 'longitude'], description: 'Destination location as coordinates', }, ], description: 'Destination location - provide as address, postal code, or coordinates', }, mode: { type: 'string', enum: ['PUBLIC_TRANSPORT', 'WALK', 'DRIVE', 'CYCLE', 'AUTO'], default: 'AUTO', description: 'Transport mode: AUTO (smart selection), PUBLIC_TRANSPORT (bus/train), WALK, DRIVE (taxi/car), CYCLE', }, departureTime: { type: 'string', description: 'Departure time in ISO format (optional)', }, arrivalTime: { type: 'string', description: 'Desired arrival time in ISO format (optional)', }, preferences: { type: 'object', properties: { maxWalkDistance: { type: 'number', minimum: 100, maximum: 2000, default: 1000, description: 'Maximum walking distance in meters', }, fastest: { type: 'boolean', default: true, description: 'Prioritize fastest route', }, cheapest: { type: 'boolean', default: false, description: 'Prioritize cheapest route', }, minimizeTransfers: { type: 'boolean', default: true, description: 'Minimize number of transfers', }, accessibilityRequired: { type: 'boolean', default: false, description: 'Ensure wheelchair accessibility', }, weatherAware: { type: 'boolean', default: true, description: 'Adjust recommendations based on weather', }, }, description: 'Journey planning preferences', }, outputOptions: { type: 'object', properties: { includeInstructions: { type: 'boolean', default: true, description: 'Include step-by-step instructions', }, includePolylines: { type: 'boolean', default: true, description: 'Include route geometry for mapping', }, includeAlternatives: { type: 'boolean', default: true, description: 'Include alternative routes', }, includeContext: { type: 'boolean', default: true, description: 'Include contextual information (landmarks, safety, etc.)', }, instructionFormat: { type: 'string', enum: ['detailed', 'simple', 'navigation'], default: 'detailed', description: 'Format for instructions: detailed (full info), simple (basic), navigation (turn-by-turn)', }, coordinateFormat: { type: 'string', enum: ['WGS84', 'decimal'], default: 'WGS84', description: 'Coordinate format for output', }, }, description: 'Output formatting options', }, maxAlternatives: { type: 'number', minimum: 1, maximum: 5, default: 3, description: 'Maximum number of alternative routes to return', }, }, ['fromLocation', 'toLocation']), }, ]; } canHandle(toolName) { return toolName === 'plan_comprehensive_journey'; } async execute(toolName, args) { const startTime = Date.now(); let apiCalls = 0; let cacheHits = 0; try { const input = validateInput(ComprehensiveJourneyInputSchema, args); logger.info('Planning comprehensive journey', { from: input.fromLocation, to: input.toLocation, mode: input.mode, preferences: input.preferences }); // Resolve locations const [fromLocation, toLocation] = await Promise.all([ this.resolveLocation(input.fromLocation), this.resolveLocation(input.toLocation), ]); apiCalls += 2; if (!fromLocation) { return this.createErrorResponse(`Could not find location: ${JSON.stringify(input.fromLocation)}`, startTime, apiCalls, cacheHits); } if (!toLocation) { return this.createErrorResponse(`Could not find location: ${JSON.stringify(input.toLocation)}`, startTime, apiCalls, cacheHits); } // Determine optimal mode if AUTO is selected const resolvedMode = input.mode === 'AUTO' ? this.selectOptimalMode(fromLocation, toLocation, input.preferences) : input.mode; logger.info('Using transport mode', { resolvedMode, distance: this.calculateDistance(fromLocation.latitude, fromLocation.longitude, toLocation.latitude, toLocation.longitude) }); // Get weather context if weather-aware routing is enabled let weatherContext = ''; if (input.preferences?.weatherAware && this.weatherService) { try { const weather = await this.weatherService.getWeatherConditionsForLocation(fromLocation.latitude, fromLocation.longitude); weatherContext = this.getWeatherRecommendations(weather, resolvedMode); apiCalls++; } catch (error) { logger.warn('Weather service unavailable, continuing without weather data', error); weatherContext = 'Weather data temporarily unavailable'; } } // Get landmarks near start and end points let startLandmarks = []; let endLandmarks = []; if (input.outputOptions?.includeContext) { try { startLandmarks = await this.getNearbyLandmarks(fromLocation); endLandmarks = await this.getNearbyLandmarks(toLocation); apiCalls += 2; } catch (error) { logger.warn('Failed to get landmarks', error); } } // Plan primary route with proper OneMap API integration const primaryRoute = await this.planPrimaryRoute(fromLocation, toLocation, resolvedMode, input); apiCalls++; if (!primaryRoute) { return this.createErrorResponse('No route found between the specified locations', startTime, apiCalls, cacheHits); } // Get alternative routes if requested const alternatives = input.outputOptions?.includeAlternatives ? await this.getAlternativeRoutes(fromLocation, toLocation, resolvedMode, input.preferences || {}, input.maxAlternatives || 3) : []; apiCalls += alternatives.length; // Get traffic and disruption information const trafficInfo = await this.getTrafficAndDisruptions(); apiCalls++; // Create comprehensive response const processingTime = Date.now() - startTime; return { success: true, journey: { summary: primaryRoute.summary, instructions: primaryRoute.instructions, formattedInstructions: primaryRoute.formattedInstructions, polylines: primaryRoute.polylines, visualization: primaryRoute.visualization, context: { fromLocation, toLocation, timeContext: this.getTimeContext(), weatherNote: weatherContext, safetyAlerts: trafficInfo.alerts, startLandmarks, endLandmarks } }, alternatives, metadata: { requestTime: new Date().toISOString(), processingTime, apiCalls, cacheHits } }; } catch (error) { logger.error(`Comprehensive journey planning failed: ${toolName}`, error); return this.createErrorResponse(error instanceof Error ? error.message : 'Unknown error occurred', startTime, apiCalls, cacheHits); } } async resolveLocation(locationInput) { // Reuse the location resolution logic from the existing journey tool try { let parsedInput = locationInput; if (typeof locationInput === 'string') { try { const parsed = JSON.parse(locationInput); if (parsed && typeof parsed === 'object') { parsedInput = parsed; } } catch { return await this.oneMapService.geocode(locationInput); } } if (parsedInput && typeof parsedInput === 'object' && 'latitude' in parsedInput && 'longitude' in parsedInput) { const lat = parseFloat(parsedInput.latitude); const lng = parseFloat(parsedInput.longitude); if (lat < 1.0 || lat > 1.5 || lng < 103.0 || lng > 104.5) { return null; } return { latitude: lat, longitude: lng, name: parsedInput.name || 'Custom Location', address: parsedInput.name || `${lat.toFixed(6)}, ${lng.toFixed(6)}`, }; } if (parsedInput && typeof parsedInput === 'object' && 'postalCode' in parsedInput) { const postalCode = parsedInput.postalCode.toString(); if (!/^\d{6}$/.test(postalCode)) { return null; } return await this.oneMapService.geocode(postalCode); } if (typeof parsedInput === 'string') { return await this.oneMapService.geocode(parsedInput); } return null; } catch (error) { logger.error('Location resolution failed', { error, input: locationInput }); return null; } } selectOptimalMode(from, to, preferences) { const distance = this.calculateDistance(from.latitude, from.longitude, to.latitude, to.longitude); if (distance < 1000) return 'WALK'; if (distance > 15000) return 'DRIVE'; return 'PUBLIC_TRANSPORT'; } buildRouteOptions(input, mode) { const options = { mode: mode === 'CYCLE' ? 'WALK' : mode, // OneMap doesn't support cycle }; if (input.departureTime) { options.departureTime = new Date(input.departureTime); } if (input.arrivalTime) { options.arrivalTime = new Date(input.arrivalTime); } if (mode === 'PUBLIC_TRANSPORT') { options.maxWalkDistance = input.preferences.maxWalkDistance; options.numItineraries = input.maxAlternatives; } return options; } formatInstructions(instructions, format) { switch (format) { case 'simple': return instructions.map(inst => inst.instruction); case 'navigation': return instructions.map(inst => { let nav = `${inst.step}. ${inst.instruction}`; if (inst.distance) { nav += ` (${inst.distance}m)`; } return nav; }); case 'detailed': default: return this.instructionParser.formatInstructionsForDisplay(instructions); } } createVisualizationData(polylines, instructions) { const stepMarkers = this.polylineService.createStepMarkers(instructions); let bounds = null; if (polylines.length > 0) { bounds = polylines[0].decoded?.bounds; } return { bounds, stepMarkers, routeGeometry: polylines.map(p => p.geojson) }; } async getContextInformation(from, to, preferences) { // Simple context information without complex LTA integration const safetyAlerts = []; // Add basic traffic information if available try { const trafficIncidents = await this.ltaService.getTrafficIncidents(); if (trafficIncidents.length > 0) { safetyAlerts.push(`${trafficIncidents.length} traffic incidents reported in Singapore`); } } catch (error) { logger.warn('Failed to get traffic incidents', error); } return { weatherNote: preferences.weatherAware ? 'Weather conditions considered in recommendations' : 'Weather awareness is off.', safetyAlerts }; } async getAlternativeRoutes(from, to, primaryMode, preferences, maxAlternatives) { // Simplified alternative route logic const alternatives = []; if (primaryMode !== 'WALK') { try { const walkRoute = await this.oneMapService.planRoute(from, to, { mode: 'WALK' }); if (walkRoute) { alternatives.push({ mode: 'WALK', summary: 'Walking route', // Would include full route data }); } } catch (error) { logger.warn('Failed to get walking alternative', error); } } return alternatives.slice(0, maxAlternatives); } calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371e3; const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos1) * Math.cos2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } getTimeContext() { const hour = new Date().getHours(); if (hour >= 7 && hour <= 9) return 'Morning Peak'; if (hour >= 17 && hour <= 19) return 'Evening Peak'; if (hour >= 23 || hour <= 5) return 'Late Night'; return 'Off Peak'; } createErrorResponse(message, startTime, apiCalls, cacheHits) { return { success: false, journey: { summary: { responseType: 'ERROR', instructionCount: 0, polylineCount: 0 }, instructions: [], formattedInstructions: [], polylines: [], visualization: { bounds: null, stepMarkers: [], routeGeometry: [] }, context: { fromLocation: { latitude: 0, longitude: 0, name: 'Unknown' }, toLocation: { latitude: 0, longitude: 0, name: 'Unknown' }, timeContext: this.getTimeContext(), weatherNote: '', safetyAlerts: [message] } }, metadata: { requestTime: new Date().toISOString(), processingTime: Date.now() - startTime, apiCalls, cacheHits } }; } getWeatherRecommendations(weather, mode) { if (!weather) return ''; const conditions = weather.condition?.toLowerCase() || ''; const temp = weather.temperature || 0; const recommendations = []; if (conditions.includes('rain') || conditions.includes('shower')) { if (mode === 'WALK') { recommendations.push('Consider covered walkways due to rain'); } else { recommendations.push('Rain detected - public transport recommended'); } } if (temp > 32) { recommendations.push('High temperature - stay hydrated and seek shade'); } if (conditions.includes('haze')) { recommendations.push('Hazy conditions - consider indoor routes if sensitive'); } return recommendations.length > 0 ? recommendations.join('. ') : 'Weather conditions are favorable for travel'; } async getNearbyLandmarks(location) { try { // Simple landmark search - in a full implementation, this would use ThemesService const landmarks = [ { name: `Landmark near ${location.name}`, distance: Math.floor(Math.random() * 500) + 100, type: 'Point of Interest' } ]; return landmarks; } catch (error) { logger.warn('Failed to get landmarks', error); return []; } } async planPrimaryRoute(from, to, mode, input) { try { logger.info('Planning primary route', { mode, from: from.name, to: to.name }); // Build proper OneMap route options const routeOptions = this.buildProperRouteOptions(mode, input); // Call OneMap routing API with correct parameters const response = await this.oneMapService.planRoute(from, to, routeOptions); if (!response) { return null; } // Process the response based on mode if (mode === 'PUBLIC_TRANSPORT') { return this.processPublicTransportResponse(response, input); } else { return this.processDirectRouteResponse(response, input); } } catch (error) { logger.error('Primary route planning failed', error); return null; } } buildProperRouteOptions(mode, input) { const now = new Date(); if (mode === 'PUBLIC_TRANSPORT') { return { mode: 'PUBLIC_TRANSPORT', departureTime: input.departureTime ? new Date(input.departureTime) : now, maxWalkDistance: input.preferences?.maxWalkDistance || 1000, numItineraries: input.maxAlternatives || 3 }; } else { return { mode: mode, departureTime: input.departureTime ? new Date(input.departureTime) : now }; } } formatDate(date) { const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const year = date.getFullYear(); return `${month}-${day}-${year}`; } formatTime(date) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${hours}${minutes}${seconds}`; } async processPublicTransportResponse(response, input) { try { // OneMapService returns a JourneyPlan object, not raw API response if (!response || !response.segments || response.segments.length === 0) { return null; } const instructions = await this.parseJourneyPlanInstructions(response); const polylines = this.extractJourneyPlanPolylines(response); return { summary: { responseType: 'PUBLIC_TRANSPORT', instructionCount: instructions.length, polylineCount: polylines.length, totalDuration: response.totalDuration, totalDistance: response.totalDistance, transfers: response.transfers || 0 }, instructions, formattedInstructions: this.formatInstructions(instructions, input.outputOptions?.instructionFormat || 'detailed'), polylines, visualization: this.createVisualizationData(polylines, instructions) }; } catch (error) { logger.error('Failed to process public transport response', error); return null; } } async processDirectRouteResponse(response, input) { try { // OneMapService returns a JourneyPlan object for direct routes too if (!response || !response.segments || response.segments.length === 0) { return null; } const instructions = await this.parseJourneyPlanInstructions(response); const polylines = this.extractJourneyPlanPolylines(response); return { summary: { responseType: 'DIRECT_ROUTING', instructionCount: instructions.length, polylineCount: polylines.length, totalDuration: response.totalDuration || 0, totalDistance: response.totalDistance || 0 }, instructions, formattedInstructions: this.formatInstructions(instructions, input.outputOptions?.instructionFormat || 'detailed'), polylines, visualization: this.createVisualizationData(polylines, instructions) }; } catch (error) { logger.error('Failed to process direct route response', error); return null; } } parsePublicTransportInstructions(itinerary) { const instructions = []; let stepNumber = 1; for (const leg of itinerary.legs || []) { if (leg.mode === 'WALK') { instructions.push({ step: stepNumber++, type: 'transit_walk', instruction: `Walk to ${leg.to.name}`, distance: Math.round(leg.distance || 0), duration: Math.round(leg.duration || 0), mode: 'WALK', coordinates: { lat: leg.from.lat, lng: leg.from.lon } }); } else { const transitMode = this.mapTransitMode(leg.mode); instructions.push({ step: stepNumber++, type: 'transit', instruction: `Take ${leg.route} ${leg.mode.toLowerCase()} from ${leg.from.name} to ${leg.to.name}`, distance: Math.round(leg.distance || 0), duration: Math.round(leg.duration || 0), mode: transitMode, coordinates: { lat: leg.from.lat, lng: leg.from.lon }, service: leg.route, operator: leg.agencyName, from: { name: leg.from.name, stopCode: leg.from.stopCode, coordinates: { lat: leg.from.lat, lng: leg.from.lon } }, to: { name: leg.to.name, stopCode: leg.to.stopCode, coordinates: { lat: leg.to.lat, lng: leg.to.lon } }, intermediateStops: leg.intermediateStops?.map((stop) => ({ name: stop.name, stopCode: stop.stopCode, coordinates: { lat: stop.lat, lng: stop.lon } })) }); } } return instructions; } parseDirectRouteInstructions(response) { const instructions = []; if (response.route_instructions) { response.route_instructions.forEach((instruction, index) => { const coords = instruction[3] ? instruction[3].split(',').map(Number) : [0, 0]; instructions.push({ step: index + 1, type: 'direct', instruction: instruction[9] || instruction[1] || 'Continue', distance: instruction[2] || 0, coordinates: { lat: coords[0], lng: coords[1] }, mode: 'WALK', direction: instruction[0], streetName: instruction[1] }); }); } return instructions; } mapTransitMode(apiMode) { switch (apiMode.toUpperCase()) { case 'WALK': return 'WALK'; case 'BUS': return 'BUS'; case 'RAIL': case 'SUBWAY': return 'SUBWAY'; case 'TRAIN': return 'TRAIN'; default: return 'WALK'; } } extractPolylines(itinerary) { const polylines = []; for (const leg of itinerary.legs || []) { if (leg.legGeometry && leg.legGeometry.points) { polylines.push({ encoded: leg.legGeometry.points, mode: leg.mode, // Would decode polyline here in full implementation }); } } return polylines; } extractDirectPolylines(response) { const polylines = []; if (response.route_geometry) { polylines.push({ encoded: response.route_geometry, mode: 'DIRECT' }); } return polylines; } async parseJourneyPlanInstructions(journeyPlan) { const instructions = []; let stepNumber = 1; for (const segment of journeyPlan.segments || []) { if (segment.mode === 'WALK') { instructions.push({ step: stepNumber++, type: 'transit_walk', instruction: segment.instructions?.[0] || `Walk to ${segment.endLocation.name}`, distance: Math.round(segment.distance || 0), duration: Math.round(segment.duration || 0), mode: 'WALK', coordinates: { lat: segment.startLocation.latitude, lng: segment.startLocation.longitude } }); } else { const transitMode = this.mapTransitMode(segment.mode); // Enhanced instruction with MRT exit recommendation let enhancedInstruction = segment.instructions?.[0] || `Take ${segment.service || segment.mode} from ${segment.startLocation.name} to ${segment.endLocation.name}`; // Add MRT exit recommendation for MRT/LRT stations if (transitMode === 'SUBWAY' || transitMode === 'TRAIN') { try { const exitRecommendation = await this.mrtExitService.findBestMRTExit(segment.endLocation.name, segment.endLocation.latitude, segment.endLocation.longitude); if (exitRecommendation) { enhancedInstruction += ` → Use ${exitRecommendation.recommendedExit.exitCode} (${exitRecommendation.walkingDistance}m walk, ${exitRecommendation.walkingTime} min)`; } } catch (error) { logger.warn('Failed to get MRT exit recommendation', error); } } instructions.push({ step: stepNumber++, type: 'transit', instruction: enhancedInstruction, distance: Math.round(segment.distance || 0), duration: Math.round(segment.duration || 0), mode: transitMode, coordinates: { lat: segment.startLocation.latitude, lng: segment.startLocation.longitude }, service: segment.service, operator: segment.operator, from: { name: segment.startLocation.name, coordinates: { lat: segment.startLocation.latitude, lng: segment.startLocation.longitude } }, to: { name: segment.endLocation.name, coordinates: { lat: segment.endLocation.latitude, lng: segment.endLocation.longitude } } }); } } return instructions; } extractJourneyPlanPolylines(journeyPlan) { const polylines = []; // For now, return empty array since JourneyPlan doesn't include polyline data // In a full implementation, this would extract encoded polylines from the journey plan return polylines; } async getTrafficAndDisruptions() { const alerts = []; try { // Get traffic incidents const incidents = await this.ltaService.getTrafficIncidents(); if (incidents.length > 0) { alerts.push(`${incidents.length} traffic incidents reported`); // Add specific incident details for major disruptions const majorIncidents = incidents.filter((incident) => incident.Message?.toLowerCase().includes('expressway') || incident.Message?.toLowerCase().includes('closure')); if (majorIncidents.length > 0) { alerts.push(`${majorIncidents.length} major road disruptions detected`); } } // Could add MRT service alerts here // const mrtAlerts = await this.ltaService.getTrainServiceAlerts(); } catch (error) { logger.warn('Failed to get traffic information', error); } return { alerts }; } }