UNPKG

@siva-sub/mcp-public-transport

Version:

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

489 lines (488 loc) 19.6 kB
import axios from 'axios'; import { APIError } from '../utils/errors.js'; import { logger } from '../utils/logger.js'; export class OneMapService { staticToken; email; password; cache; timeout; client; baseUrl = 'https://www.onemap.gov.sg/api'; tokenCache = null; constructor(staticToken, email, password, cache, timeout = 30000 // Increased from 5000 to 30000 (30 seconds) ) { this.staticToken = staticToken; this.email = email; this.password = password; this.cache = cache; this.timeout = timeout; this.client = axios.create({ baseURL: this.baseUrl, timeout: this.timeout, headers: { 'Content-Type': 'application/json', }, }); this.setupInterceptors(); } setupInterceptors() { this.client.interceptors.request.use((config) => { logger.debug(`OneMap API Request: ${config.method?.toUpperCase()} ${config.url}`, { params: config.params, }); return config; }, (error) => Promise.reject(error)); this.client.interceptors.response.use((response) => { logger.debug(`OneMap API Response: ${response.status} ${response.config.url}`); return response; }, (error) => { const status = error.response?.status; const message = error.response?.data?.error || error.message; logger.error(`OneMap API error: ${status}`, { url: error.config?.url, status, message, }); throw new APIError(`OneMap API error: ${message}`, 'ONEMAP_API_ERROR', status || 500); }); } async ensureValidToken() { // Use static token if provided if (this.staticToken) return this.staticToken; // Check cached token if (this.tokenCache && this.tokenCache.expiry > Date.now()) { return this.tokenCache.token; } // Refresh token if (!this.email || !this.password) { throw new APIError('OneMap credentials not configured', 'AUTH_MISSING', 401); } return this.refreshToken(); } async refreshToken() { try { logger.info('Refreshing OneMap authentication token'); // Use the correct authentication endpoint const response = await axios.post('https://www.onemap.gov.sg/api/auth/post/getToken', { email: this.email, password: this.password, }, { headers: { 'Content-Type': 'application/json', }, timeout: this.timeout, }); const { access_token, expiry_timestamp } = response.data; this.tokenCache = { token: access_token, expiry: parseInt(expiry_timestamp) * 1000, // Convert to milliseconds }; logger.info('OneMap token refreshed successfully', { expiry: new Date(this.tokenCache.expiry).toISOString() }); return access_token; } catch (error) { logger.error('Failed to refresh OneMap token', error); throw new APIError('OneMap authentication failed', 'AUTH_FAILED', 401); } } // Enhanced geocoding with authentication async geocodeWithAuth(query) { try { const response = await this.client.get('/common/elastic/search', { params: { searchVal: query, returnGeom: 'Y', getAddrDetails: 'Y', }, headers: this.staticToken ? { 'Authorization': this.staticToken, } : undefined, }); const data = response.data; if (data.found === 0 || !data.results || data.results.length === 0) { logger.debug(`No geocoding results found for: ${query}`); return []; } return data.results; } catch (error) { logger.error(`Geocoding with auth failed for: ${query}`, error); throw error; } } async geocode(query) { const cacheKey = `geocode_${query.toLowerCase()}`; if (this.cache) { return this.cache.getOrSet(cacheKey, async () => { return this._geocode(query); }, 3600); // 1 hour cache } return this._geocode(query); } async _geocode(query) { try { const response = await this.client.get('/common/elastic/search', { params: { searchVal: query, returnGeom: 'Y', getAddrDetails: 'Y', }, }); const data = response.data; if (data.found === 0 || !data.results || data.results.length === 0) { logger.warn(`No geocoding results found for: ${query}`); return null; } const result = data.results[0]; return { latitude: parseFloat(result.LATITUDE), longitude: parseFloat(result.LONGITUDE), name: result.BUILDING || result.ROAD_NAME, address: result.ADDRESS, }; } catch (error) { logger.error(`Geocoding failed for: ${query}`, error); return null; } } async planRoute(from, to, options = {}) { const { mode = 'PUBLIC_TRANSPORT', departureTime, arrivalTime, maxWalkDistance = 1000, numItineraries = 3, } = options; const cacheKey = `route_${from.latitude}_${from.longitude}_${to.latitude}_${to.longitude}_${mode}`; if (this.cache) { return this.cache.getOrSet(cacheKey, async () => { return this._planRoute(from, to, { mode, departureTime, arrivalTime, maxWalkDistance, numItineraries, }); }, 300); // 5 minute cache } return this._planRoute(from, to, options); } async _planRoute(from, to, options) { try { // Get authentication token for routing const token = await this.ensureValidToken(); const routeType = this.mapModeToRouteType(options.mode || 'PUBLIC_TRANSPORT'); // Format coordinates properly for OneMap API (no encoding needed) const params = { start: `${from.latitude},${from.longitude}`, end: `${to.latitude},${to.longitude}`, routeType, }; // Add mode-specific parameters if (options.mode === 'PUBLIC_TRANSPORT') { params.mode = 'TRANSIT'; params.maxWalkDistance = (options.maxWalkDistance || 1000).toString(); params.numItineraries = (options.numItineraries || 3).toString(); // Add time parameters for PT const now = new Date(); if (options.departureTime) { params.date = this.formatDateForOneMap(options.departureTime); params.time = this.formatTimeForOneMap(options.departureTime); } else { params.date = this.formatDateForOneMap(now); params.time = this.formatTimeForOneMap(now); } } // Use the correct public API endpoint const routingUrl = 'https://www.onemap.gov.sg/api/public/routingsvc/route'; logger.debug('Making OneMap routing request', { url: routingUrl, params, mode: options.mode }); // Make request with Bearer token in Authorization header const response = await axios.get(routingUrl, { params, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, timeout: this.timeout, }); logger.debug('OneMap routing response received', { status: response.status, hasData: !!response.data, hasPlan: !!response.data.plan, hasInstructions: !!response.data.route_instructions }); // Check response status for direct routing (walk/drive) if (response.data.status !== undefined && response.data.status !== 0) { logger.warn('OneMap routing failed', { status: response.data.status, message: response.data.status_message, params }); return null; } // Check if we have a valid response if (!response.data.plan?.itineraries?.length && !response.data.route_instructions) { logger.warn('No route found in response', { from: from.name, to: to.name, params, responseKeys: Object.keys(response.data) }); return null; } return this.formatRouteResponse(response.data); } catch (error) { logger.error('Route planning failed', { error: error instanceof Error ? error.message : 'Unknown error', from: from.name, to: to.name, mode: options.mode, status: error?.response?.status, statusText: error?.response?.statusText, responseData: error?.response?.data }); // Handle specific error cases if (error?.response?.status === 401) { logger.warn('Authentication failed, token may be expired'); // Clear token cache to force refresh on next request this.tokenCache = null; } return null; } } mapModeToRouteType(mode) { switch (mode) { case 'PUBLIC_TRANSPORT': return 'pt'; case 'WALK': return 'walk'; case 'DRIVE': return 'drive'; default: return 'pt'; } } formatDate(date) { return date.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', }); } formatTime(date) { return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', }); } formatDateForOneMap(date) { // OneMap expects MM-DD-YYYY format 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}`; } formatTimeForOneMap(date) { // OneMap expects HH:MM:SS format 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}`; } formatRouteResponse(data) { // Handle public transport response (with plan.itineraries) if (data.plan?.itineraries?.length) { return this.formatPublicTransportResponse(data); } // Handle direct routing response (walk/drive/cycle with route_instructions) if (data.route_instructions?.length) { return this.formatDirectRoutingResponse(data); } throw new APIError('Invalid route response format', 'INVALID_ROUTE_RESPONSE', 422); } formatPublicTransportResponse(data) { const itinerary = data.plan.itineraries[0]; // Use first/best itinerary const segments = itinerary.legs.map((leg) => { const instructions = []; if (leg.mode === 'WALK') { if (leg.steps) { instructions.push(...leg.steps.map(step => `${step.relativeDirection} on ${step.streetName} for ${step.distance}m`)); } else { instructions.push(`Walk ${leg.distance}m to ${leg.to.name}`); } } else { instructions.push(`Take ${leg.routeShortName || leg.route} from ${leg.from.name} to ${leg.to.name}`); if (leg.agencyName) { instructions.push(`Operated by ${leg.agencyName}`); } } return { mode: this.mapApiModeToMode(leg.mode), duration: leg.duration, distance: leg.distance, instructions, startLocation: { latitude: leg.from.lat, longitude: leg.from.lon, name: leg.from.name, }, endLocation: { latitude: leg.to.lat, longitude: leg.to.lon, name: leg.to.name, }, service: leg.routeShortName, operator: leg.agencyName, }; }); const totalCost = parseFloat(itinerary.fare) || 0; return { segments, totalDuration: itinerary.duration, totalDistance: itinerary.walkDistance, totalCost, totalWalkDistance: itinerary.walkDistance, transfers: itinerary.transfers, summary: this.createJourneySummary(segments, itinerary), }; } formatDirectRoutingResponse(data) { const instructions = []; let startCoords = { latitude: 0, longitude: 0 }; let endCoords = { latitude: 0, longitude: 0 }; // Process detailed turn-by-turn instructions if (data.route_instructions) { for (let i = 0; i < data.route_instructions.length; i++) { const instruction = data.route_instructions[i]; const [direction, streetName, distance, coordinates, timeSeconds, distanceFormatted, fromDirection, toDirection, mode, instructionText] = instruction; // Parse coordinates from the first and last instructions if (i === 0 && coordinates) { const coords = coordinates.split(','); if (coords.length >= 2) { startCoords = { latitude: parseFloat(coords[0]), longitude: parseFloat(coords[1]) }; } } if (i === data.route_instructions.length - 1 && coordinates) { const coords = coordinates.split(','); if (coords.length >= 2) { endCoords = { latitude: parseFloat(coords[0]), longitude: parseFloat(coords[1]) }; } } // Use the detailed instruction text from OneMap if (instructionText && instructionText.trim()) { instructions.push(instructionText); } else { // Fallback to constructing instruction from components if (streetName && streetName.trim()) { instructions.push(`${direction} on ${streetName} for ${distanceFormatted || distance + 'm'}`); } else { instructions.push(`${direction} for ${distanceFormatted || distance + 'm'}`); } } } } const mode = this.determineDirectRouteMode(data.route_instructions?.[0]?.[8] || 'walking'); const totalTime = data.route_summary?.total_time || 0; const totalDistance = data.route_summary?.total_distance || 0; const segments = [{ mode, duration: totalTime, distance: totalDistance, instructions, startLocation: { latitude: startCoords.latitude, longitude: startCoords.longitude, name: data.route_summary?.start_point || 'Origin', }, endLocation: { latitude: endCoords.latitude, longitude: endCoords.longitude, name: data.route_summary?.end_point || 'Destination', }, }]; return { segments, totalDuration: totalTime, totalDistance: totalDistance, totalCost: 0, // No cost for walking/driving totalWalkDistance: mode === 'WALK' ? totalDistance : 0, transfers: 0, summary: this.createDirectRouteSummary(mode, totalTime, totalDistance), }; } determineDirectRouteMode(apiMode) { switch (apiMode?.toLowerCase()) { case 'walking': return 'WALK'; case 'driving': return 'TAXI'; // Use TAXI for driving mode case 'cycling': return 'WALK'; // Use WALK for cycling (closest equivalent) default: return 'WALK'; } } createDirectRouteSummary(mode, timeSeconds, distanceMeters) { const minutes = Math.round(timeSeconds / 60); const modeText = mode === 'TAXI' ? 'driving' : mode.toLowerCase(); if (distanceMeters < 1000) { return `${minutes} min ${modeText} (${distanceMeters}m)`; } else { const km = (distanceMeters / 1000).toFixed(1); return `${minutes} min ${modeText} (${km}km)`; } } mapApiModeToMode(apiMode) { switch (apiMode.toUpperCase()) { case 'WALK': return 'WALK'; case 'BUS': return 'BUS'; case 'RAIL': case 'SUBWAY': return 'TRAIN'; default: return 'WALK'; } } createJourneySummary(segments, itinerary) { const duration = Math.round(itinerary.duration / 60); const cost = parseFloat(itinerary.fare) || 0; const transfers = itinerary.transfers; const modeCount = segments.reduce((acc, segment) => { acc[segment.mode] = (acc[segment.mode] || 0) + 1; return acc; }, {}); const modeDescriptions = []; if (modeCount.BUS) modeDescriptions.push(`${modeCount.BUS} bus${modeCount.BUS > 1 ? 'es' : ''}`); if (modeCount.TRAIN) modeDescriptions.push(`${modeCount.TRAIN} train${modeCount.TRAIN > 1 ? 's' : ''}`); if (modeCount.WALK) modeDescriptions.push('walking'); let summary = `${duration} min via ${modeDescriptions.join(' + ')}`; if (cost > 0) summary += `, $${cost.toFixed(2)}`; if (transfers > 0) summary += `, ${transfers} transfer${transfers > 1 ? 's' : ''}`; return summary; } }