@siva-sub/mcp-public-transport
Version:
A Model Context Protocol server for Singapore transport data with real-time information and routing
219 lines (218 loc) • 8.54 kB
JavaScript
import axios from 'axios';
import { APIError, RateLimitError } from '../utils/errors.js';
import { logger } from '../utils/logger.js';
export class LTAService {
accountKey;
cache;
timeout;
client; // Changed to protected
baseUrl = 'https://datamall2.mytransport.sg/ltaodataservice';
constructor(accountKey, cache, timeout = 30000) {
this.accountKey = accountKey;
this.cache = cache;
this.timeout = timeout;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: this.timeout,
headers: {
'AccountKey': this.accountKey,
'Accept': 'application/json',
},
});
this.setupInterceptors();
}
setupInterceptors() {
// Request interceptor for logging
this.client.interceptors.request.use((config) => {
logger.debug(`LTA API Request: ${config.method?.toUpperCase()} ${config.url}`, {
params: config.params,
});
return config;
}, (error) => Promise.reject(error));
// Response interceptor for error handling
this.client.interceptors.response.use((response) => {
logger.debug(`LTA API Response: ${response.status} ${response.config.url}`, {
dataLength: JSON.stringify(response.data).length,
});
return response;
}, (error) => {
const status = error.response?.status;
const message = error.response?.data || error.message;
logger.error(`LTA API error: ${status}`, {
url: error.config?.url,
status,
message,
});
if (status === 429) {
throw new RateLimitError(`LTA API rate limit exceeded: ${message}`);
}
if (status === 401) {
throw new APIError('Invalid LTA API key', 'LTA_AUTH_ERROR', status);
}
if (status && status >= 500) {
throw new APIError(`LTA API server error: ${message}`, 'LTA_SERVER_ERROR', status);
}
throw new APIError(`LTA API error: ${message}`, 'LTA_API_ERROR', status || 500);
});
}
async getBusArrival(busStopCode, serviceNo) {
const cacheKey = `bus_arrival_${busStopCode}_${serviceNo || 'all'}`;
return this.cache.getOrSet(cacheKey, async () => {
const params = { BusStopCode: busStopCode };
if (serviceNo) {
params.ServiceNo = serviceNo;
}
const response = await this.client.get('/v3/BusArrival', { params });
return this.formatBusArrival(response.data);
}, 30); // 30 second cache for real-time data
}
async getBusStops(skip = 0, limit = 500) {
const cacheKey = `bus_stops_${skip}_${limit}`;
return this.cache.getOrSet(cacheKey, async () => {
const response = await this.client.get('/BusStops', {
params: { $skip: skip, $top: limit }
});
return response.data.value.map((stop) => ({
busStopCode: stop.BusStopCode,
roadName: stop.RoadName,
description: stop.Description,
latitude: stop.Latitude,
longitude: stop.Longitude,
}));
}, 3600); // 1 hour cache for static data
}
async getAllBusStops() {
const cacheKey = 'all_bus_stops';
return this.cache.getOrSet(cacheKey, async () => {
const allStops = [];
let skip = 0;
const limit = 500;
while (true) {
const stops = await this.getBusStops(skip, limit);
allStops.push(...stops);
if (stops.length < limit) {
break;
}
skip += limit;
}
logger.info(`Loaded ${allStops.length} bus stops`);
return allStops;
}, 86400); // 24 hour cache
}
async getTrainServiceAlerts() {
const cacheKey = 'train_service_alerts';
return this.cache.getOrSet(cacheKey, async () => {
const response = await this.client.get('/TrainServiceAlerts');
return this.formatTrainAlerts(response.data);
}, 60); // 1 minute cache
}
async getTaxiAvailability() {
const cacheKey = 'taxi_availability';
return this.cache.getOrSet(cacheKey, async () => {
const response = await this.client.get('/Taxi-Availability');
return response.data.value.map((taxi) => ({
coordinates: [taxi.Latitude, taxi.Longitude],
timestamp: taxi.Timestamp,
taxiCount: taxi.NoOfTaxis, // Map NoOfTaxis to taxiCount
}));
}, 30); // 30 second cache
}
async getTrafficIncidents() {
const cacheKey = 'traffic_incidents';
return this.cache.getOrSet(cacheKey, async () => {
const response = await this.client.get('/TrafficIncidents');
return response.data.value.map((incident) => ({
type: incident.Type,
message: incident.Message,
coordinates: incident.Latitude && incident.Longitude
? [incident.Latitude, incident.Longitude]
: undefined,
}));
}, 120); // 2 minute cache
}
formatBusArrival(data) {
if (!data.Services) {
return [];
}
return data.Services.map((service) => ({
serviceNo: service.ServiceNo,
operator: this.getOperatorName(service.Operator),
nextBuses: [service.NextBus, service.NextBus2, service.NextBus3]
.filter(bus => bus?.EstimatedArrival)
.map(bus => ({
estimatedArrival: bus.EstimatedArrival,
minutesAway: this.calculateMinutesAway(bus.EstimatedArrival),
loadStatus: bus.Load,
loadDescription: this.formatLoadStatus(bus.Load),
wheelchairAccessible: bus.Feature === 'WAB',
busType: bus.Type,
busTypeDescription: this.formatBusType(bus.Type),
latitude: bus.Latitude,
longitude: bus.Longitude,
visitNumber: bus.VisitNumber,
})),
})).filter(service => service.nextBuses.length > 0);
}
formatTrainAlerts(data) {
if (data.Status === 1) {
return []; // No alerts
}
const alerts = [];
if (data.AffectedSegments) {
alerts.push(...data.AffectedSegments.map((segment) => ({
line: segment.Line,
lineName: this.getLineName(segment.Line),
status: 'Disrupted',
direction: segment.Direction,
affectedStations: segment.Stations?.split(',').map(s => s.trim()) || [],
alternativeTransport: segment.FreePublicBus === 'Y',
shuttleService: segment.FreeMRTShuttle === 'Y',
})));
}
return alerts;
}
getOperatorName(code) {
const operators = {
'SBST': 'SBS Transit',
'SMRT': 'SMRT Buses',
'TTS': 'Tower Transit Singapore',
'GAS': 'Go-Ahead Singapore',
};
return operators[code] || code;
}
getLineName(code) {
const lines = {
'EWL': 'East West Line',
'NSL': 'North South Line',
'CCL': 'Circle Line',
'DTL': 'Downtown Line',
'NEL': 'North East Line',
'BPL': 'Bukit Panjang LRT',
'SLRT': 'Sengkang LRT',
'PLRT': 'Punggol LRT',
'TEL': 'Thomson-East Coast Line',
};
return lines[code] || code;
}
formatLoadStatus(load) {
const loads = {
'SEA': 'Seats Available',
'SDA': 'Standing Available',
'LSD': 'Limited Standing',
};
return loads[load] || load || 'Unknown';
}
formatBusType(busType) {
const types = {
'SD': 'Single Deck',
'DD': 'Double Deck',
'BD': 'Bendy Bus',
};
return types[busType] || busType || 'Unknown';
}
calculateMinutesAway(arrivalTime) {
const arrival = new Date(arrivalTime);
const now = new Date();
return Math.max(0, Math.floor((arrival.getTime() - now.getTime()) / 60000));
}
}