UNPKG

@siva-sub/mcp-public-transport

Version:

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

272 lines (271 loc) 13.2 kB
import { z } from 'zod'; import { BaseTool } from '../base.js'; import { logger } from '../../utils/logger.js'; const EnhancedJourneyInputSchema = z.object({ origin: z.object({ latitude: z.number().optional(), longitude: z.number().optional(), name: z.string().optional(), postalCode: z.string().optional(), }), destination: z.object({ latitude: z.number().optional(), longitude: z.number().optional(), name: z.string().optional(), postalCode: z.string().optional(), }), preferences: z.object({ minimizeTransfers: z.boolean().default(true), minimizeWalkingTime: z.boolean().default(false), minimizeTotalTime: z.boolean().default(true), allowDriving: z.boolean().default(false), accessibilityRequired: z.boolean().default(false), maxWalkingDistance: z.number().min(100).max(2000).default(1000), departureTime: z.string().optional(), }).default({}), includeWeatherImpact: z.boolean().default(true), includeRealTimeDisruptions: z.boolean().default(true), maxAlternatives: z.number().min(1).max(5).default(3), }); export class EnhancedJourneyPlanningTool extends BaseTool { routingService; constructor(routingService) { super(); this.routingService = routingService; } getDefinitions() { return [ { name: 'plan_optimal_journey', description: 'Plan an optimal journey with minimal transfers, real-time disruptions, weather impact, and detailed turn-by-turn instructions. Supports public transport, walking, and driving options.', inputSchema: this.createSchema({ origin: { type: 'object', description: 'Starting location - provide coordinates, name, or postal code', properties: { latitude: { type: 'number', description: 'Latitude coordinate' }, longitude: { type: 'number', description: 'Longitude coordinate' }, name: { type: 'string', description: 'Location name (e.g., "Orchard MRT", "Marina Bay Sands")' }, postalCode: { type: 'string', description: 'Singapore postal code (6 digits)' }, }, }, destination: { type: 'object', description: 'Destination location - provide coordinates, name, or postal code', properties: { latitude: { type: 'number', description: 'Latitude coordinate' }, longitude: { type: 'number', description: 'Longitude coordinate' }, name: { type: 'string', description: 'Location name (e.g., "Changi Airport", "NUS")' }, postalCode: { type: 'string', description: 'Singapore postal code (6 digits)' }, }, }, preferences: { type: 'object', description: 'Journey planning preferences', properties: { minimizeTransfers: { type: 'boolean', default: true, description: 'Prioritize routes with fewer transfers', }, minimizeWalkingTime: { type: 'boolean', default: false, description: 'Minimize walking time over total time', }, minimizeTotalTime: { type: 'boolean', default: true, description: 'Prioritize fastest overall journey', }, allowDriving: { type: 'boolean', default: false, description: 'Include driving directions as an option', }, accessibilityRequired: { type: 'boolean', default: false, description: 'Ensure wheelchair accessibility', }, maxWalkingDistance: { type: 'number', minimum: 100, maximum: 2000, default: 1000, description: 'Maximum walking distance in meters', }, departureTime: { type: 'string', description: 'Departure time (ISO format) - defaults to now', }, }, }, includeWeatherImpact: { type: 'boolean', default: true, description: 'Include real-time weather impact on walking times', }, includeRealTimeDisruptions: { type: 'boolean', default: true, description: 'Include current service disruptions and alternatives', }, maxAlternatives: { type: 'number', minimum: 1, maximum: 5, default: 3, description: 'Maximum number of alternative routes to return', }, }, ['origin', 'destination']), }, ]; } canHandle(toolName) { return toolName === 'plan_optimal_journey'; } async execute(toolName, args) { try { const { origin, destination, preferences, includeWeatherImpact, includeRealTimeDisruptions, maxAlternatives, } = EnhancedJourneyInputSchema.parse(args); logger.info('Planning enhanced journey', { origin: origin.name || `${origin.latitude},${origin.longitude}`, destination: destination.name || `${destination.latitude},${destination.longitude}`, preferences, }); // Resolve origin and destination coordinates if needed const resolvedOrigin = await this.resolveLocation(origin); const resolvedDestination = await this.resolveLocation(destination); if (!resolvedOrigin || !resolvedDestination) { return { error: 'Could not resolve origin or destination coordinates', suggestions: [ 'Provide valid coordinates (latitude, longitude)', 'Use a recognizable location name', 'Provide a valid Singapore postal code', ], timestamp: new Date().toISOString(), }; } // Plan the journey const journeyPlans = await this.routingService.planOptimalJourney(resolvedOrigin, resolvedDestination, preferences); if (journeyPlans.length === 0) { return { error: 'No viable routes found', suggestions: [ 'Try increasing maxWalkingDistance', 'Consider allowing driving if destinations are far apart', 'Check if locations are accessible by public transport', ], timestamp: new Date().toISOString(), }; } // Format the response const primaryRoute = journeyPlans[0]; const alternativeRoutes = journeyPlans.slice(1, maxAlternatives); return { success: true, primaryRoute: { summary: primaryRoute.summary, segments: primaryRoute.segments.map(segment => ({ mode: segment.mode, duration: `${Math.round(segment.duration / 60)} minutes`, distance: segment.distance > 1000 ? `${(segment.distance / 1000).toFixed(1)} km` : `${Math.round(segment.distance)} m`, instructions: segment.instructions, startLocation: segment.startLocation, endLocation: segment.endLocation, line: segment.line, service: segment.service, stations: segment.stations, stops: segment.stops, realTimeInfo: segment.realTimeInfo, platformInfo: segment.platformInfo, })), weatherImpact: includeWeatherImpact ? primaryRoute.weatherImpact : undefined, disruptions: includeRealTimeDisruptions ? primaryRoute.disruptions : undefined, confidence: primaryRoute.confidence, }, alternativeRoutes: alternativeRoutes.map(route => ({ summary: route.summary, confidence: route.confidence, reason: this.generateAlternativeReason(route, primaryRoute), })), metadata: { totalRoutesFound: journeyPlans.length, searchPreferences: preferences, weatherImpactIncluded: includeWeatherImpact, realTimeDataIncluded: includeRealTimeDisruptions, planningTime: new Date().toISOString(), }, timestamp: new Date().toISOString(), }; } catch (error) { logger.error(`Enhanced journey planning failed: ${toolName}`, error); return this.formatError(error, toolName); } } async resolveLocation(location) { // If coordinates are provided, use them directly if (location.latitude && location.longitude) { return { latitude: location.latitude, longitude: location.longitude, name: location.name, }; } try { // Try to resolve using OneMap geocoding service if (location.name) { // Use the routing service's OneMap service to geocode the location const geocodeResult = await this.routingService['oneMapService'].geocode(location.name); if (geocodeResult) { return { latitude: geocodeResult.latitude, longitude: geocodeResult.longitude, name: location.name, }; } } // Try postal code resolution if available if (location.postalCode) { // Extract postal code from name if it contains one const postalCodeMatch = location.name?.match(/\b\d{6}\b/); const postalCode = location.postalCode || postalCodeMatch?.[0]; if (postalCode) { const geocodeResult = await this.routingService['oneMapService'].geocode(postalCode); if (geocodeResult) { return { latitude: geocodeResult.latitude, longitude: geocodeResult.longitude, name: location.name || geocodeResult.address, }; } } } logger.warn('Could not resolve location', { location }); return null; } catch (error) { logger.error('Location resolution failed', error); return null; } } generateAlternativeReason(alternative, primary) { if (alternative.summary.transfers < primary.summary.transfers) { return `Fewer transfers (${alternative.summary.transfers} vs ${primary.summary.transfers})`; } if (alternative.summary.walkingTime < primary.summary.walkingTime) { return `Less walking time (${Math.round(alternative.summary.walkingTime / 60)} vs ${Math.round(primary.summary.walkingTime / 60)} minutes)`; } if (alternative.summary.modes.includes('DRIVE')) { return 'Driving option'; } if (alternative.summary.totalCost < primary.summary.totalCost) { return `Lower cost ($${alternative.summary.totalCost.toFixed(2)} vs $${primary.summary.totalCost.toFixed(2)})`; } return 'Alternative route option'; } }