Skip to main content
Glama
gtfs.tools.ts87.2 kB
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import axios from 'axios'; import * as GtfsRealtimeBindings from 'gtfs-realtime-bindings'; import JSZip from 'jszip'; import csvParser from 'csv-parser'; import { Readable } from 'stream'; import { prefixToolName } from './utils/tool-naming.js'; import { LocationClient, SearchPlaceIndexForTextCommand } from '@aws-sdk/client-location'; // API Base URL for Malaysia Open Data API const API_BASE_URL = 'https://api.data.gov.my'; // GTFS endpoints const GTFS_STATIC_ENDPOINT = '/gtfs-static'; const GTFS_REALTIME_ENDPOINT = '/gtfs-realtime/vehicle-position'; const GTFS_TRIP_UPDATES_ENDPOINT = '/gtfs-realtime/trip-update'; // Real-time data availability note const REALTIME_DATA_NOTE = "Real-time data access through this MCP is limited. For up-to-date train and bus schedules, bus locations, and arrivals in real-time, please use these apps: Google Maps (Penang, Kuala Lumpur, Selangor, Putrajaya, Kuantan, Johor Bahru), MyRapid PULSE (Penang, Kuala Lumpur, Selangor, Putrajaya, Kuantan), Moovit (Penang, Kuala Lumpur, Selangor, Putrajaya, Kuantan, Johor Bahru), or Lugo (Johor Bahru)."; // Error note for 404 errors const ERROR_404_NOTE = "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required."; // Combined note for error responses const COMBINED_ERROR_NOTE = `${ERROR_404_NOTE} ${REALTIME_DATA_NOTE}`; // Geocoding APIs const GOOGLE_MAPS_GEOCODING_API = 'https://maps.googleapis.com/maps/api/geocode/json'; const NOMINATIM_API = 'https://nominatim.openstreetmap.org/search'; // Google Maps API Key from environment variable // We'll determine this dynamically in the geocodeLocation function to ensure // it picks up any changes made after server initialization let googleMapsApiKeyLastChecked = 0; let cachedGoogleMapsApiKey = ''; function getGoogleMapsApiKey(): string { // Only check once per minute to avoid excessive environment variable lookups const now = Date.now(); if (now - googleMapsApiKeyLastChecked > 60000) { cachedGoogleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY || ''; googleMapsApiKeyLastChecked = now; if (!cachedGoogleMapsApiKey) { console.log('No Google Maps API key found. Using Nominatim API for geocoding as fallback.'); } else { console.log('Using Google Maps API for geocoding.'); } } return cachedGoogleMapsApiKey; } // Valid providers and categories const VALID_PROVIDERS = ['mybas-johor', 'ktmb', 'prasarana']; // Valid categories for Prasarana const PRASARANA_CATEGORIES = [ 'rapid-bus-penang', 'rapid-bus-kuantan', 'rapid-bus-mrtfeeder', 'rapid-rail-kl', 'rapid-bus-kl' ]; // Common name mappings to help with user queries const PROVIDER_MAPPINGS: Record<string, { provider: string; category?: string }> = { // Direct provider mappings 'mybas': { provider: 'mybas-johor' }, 'mybas johor': { provider: 'mybas-johor' }, 'mybas johor bahru': { provider: 'mybas-johor' }, 'ktmb': { provider: 'ktmb' }, 'ktm': { provider: 'ktmb' }, 'keretapi tanah melayu': { provider: 'ktmb' }, 'keretapi tanah melayu berhad': { provider: 'ktmb' }, 'prasarana': { provider: 'prasarana', category: 'rapid-rail-kl' }, // Prasarana services (mapped to provider + category) 'rapid rail': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'rapid rail kl': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'rapid kl rail': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'rapid-rail': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'rapid-rail-kl': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'mrt': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'lrt': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'monorail': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'monorel': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'kl mrt': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'kl lrt': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'kl monorail': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'kl monorel': { provider: 'prasarana', category: 'rapid-rail-kl' }, 'rapid kl bus': { provider: 'prasarana', category: 'rapid-bus-kl' }, 'rapid bus kl': { provider: 'prasarana', category: 'rapid-bus-kl' }, 'rapid kl': { provider: 'prasarana', category: 'rapid-rail-kl' }, // Default to rail when just 'rapid kl' is specified 'rapid penang': { provider: 'prasarana', category: 'rapid-bus-penang' }, 'rapid bus penang': { provider: 'prasarana', category: 'rapid-bus-penang' }, 'rapid kuantan': { provider: 'prasarana', category: 'rapid-bus-kuantan' }, 'rapid bus kuantan': { provider: 'prasarana', category: 'rapid-bus-kuantan' }, 'mrt feeder': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' }, 'rapid feeder': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' }, 'rapid feeder kl': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' }, 'rapid bus mrt feeder': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' } }; /** * Normalize provider and category from user input * @param provider Provider name from user input * @param category Optional category from user input * @returns Normalized provider and category */ function normalizeProviderAndCategory(provider: string, category?: string): { provider: string; category?: string; error?: string } { // Convert to lowercase for case-insensitive matching const normalizedProvider = provider.toLowerCase(); let normalizedCategory = category; // Check if this is a known provider/service in our mappings if (PROVIDER_MAPPINGS[normalizedProvider]) { return PROVIDER_MAPPINGS[normalizedProvider]; } // If not in mappings, check if it's a valid provider if (!VALID_PROVIDERS.includes(normalizedProvider)) { return { provider, category, error: `Invalid provider: ${provider}. Valid providers are: ${VALID_PROVIDERS.join(', ')}` }; } // For prasarana, validate the category if (normalizedProvider === 'prasarana') { if (!category) { return { provider: normalizedProvider, error: 'Category parameter is required for prasarana provider' }; } // Normalize category to lowercase for case-insensitive matching normalizedCategory = category.toLowerCase(); if (!PRASARANA_CATEGORIES.includes(normalizedCategory)) { return { provider: normalizedProvider, category, error: `Invalid category for prasarana: ${category}. Valid categories are: ${PRASARANA_CATEGORIES.join(', ')}` }; } } // Return normalized values return { provider: normalizedProvider, category: normalizedCategory }; } // Export geocoding functions for testing export { geocodeLocation, geocodeWithGrabMaps, geocodeWithNominatim, haversineDistance }; // Cache for GTFS data to avoid repeated downloads and parsing const gtfsCache = { static: new Map<string, { data: any; timestamp: number }>(), realtime: new Map<string, { data: any; timestamp: number }>(), tripUpdates: new Map<string, { data: any; timestamp: number }>(), }; // Cache expiry times (in milliseconds) const STATIC_CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours const REALTIME_CACHE_EXPIRY = 30 * 1000; // 30 seconds const TRIP_UPDATES_CACHE_EXPIRY = 30 * 1000; // 30 seconds /** * Parse CSV data from a readable stream * @param stream Readable stream containing CSV data * @returns Promise resolving to an array of parsed objects */ async function parseCsv(stream: Readable): Promise<any[]> { return new Promise((resolve, reject) => { const results: any[] = []; stream .pipe(csvParser()) .on('data', (data) => results.push(data)) .on('end', () => resolve(results)) .on('error', (error) => reject(error)); }); } /** * Parse GTFS Static data from a ZIP file * @param buffer Buffer containing the ZIP file * @returns Promise resolving to parsed GTFS data */ async function parseGtfsStaticZip(buffer: Buffer): Promise<Record<string, any[]>> { const zip = new JSZip(); const contents = await zip.loadAsync(buffer); const result: Record<string, any[]> = {}; // List of core GTFS files to parse const coreFiles = [ 'agency.txt', 'stops.txt', 'routes.txt', 'trips.txt', 'stop_times.txt', 'calendar.txt', 'calendar_dates.txt', 'shapes.txt', 'frequencies.txt', ]; // Parse each file in the ZIP for (const fileName of Object.keys(contents.files)) { // Skip directories and non-core files if (contents.files[fileName].dir || !coreFiles.includes(fileName)) { continue; } try { // Get file content as text const fileData = await contents.files[fileName].async('nodebuffer'); const stream = Readable.from(fileData); // Parse CSV data const parsedData = await parseCsv(stream); // Store parsed data const fileNameWithoutExt = fileName.replace('.txt', ''); result[fileNameWithoutExt] = parsedData; } catch (error) { console.error(`Error parsing ${fileName}:`, error); } } return result; } /** * Enhance location query with Malaysian context if needed * @param query Original location query * @returns Enhanced query with better context for geocoding */ function enhanceLocationQuery(query: string): string { // Don't modify if already contains state/country information const malaysianStates = ['penang', 'pulau pinang', 'selangor', 'kuala lumpur', 'kl', 'johor', 'kedah', 'kelantan', 'melaka', 'malacca', 'negeri sembilan', 'pahang', 'perak', 'perlis', 'sabah', 'sarawak', 'terengganu', 'labuan', 'putrajaya']; // Check if query already contains state information const lowercaseQuery = query.toLowerCase(); const hasStateInfo = malaysianStates.some(state => lowercaseQuery.includes(state)); if (hasStateInfo || lowercaseQuery.includes('malaysia')) { return query; // Already has sufficient context } // Special handling for specific hotels in Penang const penangHotels = [ 'hompton hotel', 'cititel', 'g hotel', 'eastern & oriental', 'e&o hotel', 'shangri-la', 'shangri la', 'holiday inn', 'tune hotel', 'hotel jen', 'the light', 'lexis suites', 'hard rock hotel', 'bayview', 'equatorial', 'four points', 'vouk hotel', 'neo+', 'neo plus', 'royale chulan', 'the wembley', 'sunway hotel', 'hotel royal', 'st giles', 'flamingo' ]; // Check if query contains any Penang hotel names if (penangHotels.some(hotel => lowercaseQuery.includes(hotel))) { return `${query}, Penang, Malaysia`; } // Check for common hotel chains or landmarks that might need context if (lowercaseQuery.includes('hotel') || lowercaseQuery.includes('mall') || lowercaseQuery.includes('airport')) { // Check for Penang-specific locations if (lowercaseQuery.includes('bayan lepas') || lowercaseQuery.includes('georgetown') || lowercaseQuery.includes('george town') || lowercaseQuery.includes('butterworth') || lowercaseQuery.includes('bukit mertajam') || lowercaseQuery.includes('batu ferringhi')) { return `${query}, Penang, Malaysia`; } // Add Malaysia as context to improve geocoding results return `${query}, Malaysia`; } return query; } // Get GrabMaps API key from environment variable let grabMapsApiKeyLastChecked = 0; let cachedGrabMapsApiKey = ''; function getGrabMapsApiKey(): string { // Only check once per minute to avoid excessive environment variable lookups const now = Date.now(); if (now - grabMapsApiKeyLastChecked > 60000) { cachedGrabMapsApiKey = process.env.GRABMAPS_API_KEY || ''; grabMapsApiKeyLastChecked = now; if (!cachedGrabMapsApiKey) { console.log('No GrabMaps API key found.'); } else { console.log('GrabMaps API key available.'); } } return cachedGrabMapsApiKey; } /** * Geocode a location name to coordinates using available providers with fallback * @param query Location name to geocode * @param country Optional country code to limit results (e.g., 'my' for Malaysia) * @returns Promise with coordinates or null if not found */ async function geocodeLocation(query: string, country: string = 'my'): Promise<{ lat: number; lon: number } | null> { try { // Enhance the query with better context const enhancedQuery = enhanceLocationQuery(query); // Get API keys for different providers const googleMapsApiKey = getGoogleMapsApiKey(); const grabMapsApiKey = getGrabMapsApiKey(); // Try GrabMaps first for Southeast Asian countries (preferred for the region) const seaCountries = ['my', 'sg', 'id', 'th', 'ph', 'vn', 'mm', 'la', 'kh', 'bn', 'tl']; if (grabMapsApiKey && seaCountries.includes(country.toLowerCase())) { console.log('Attempting to geocode with GrabMaps (preferred for Southeast Asia)'); const grabMapsResult = await geocodeWithGrabMaps(enhancedQuery, query, country, grabMapsApiKey); if (grabMapsResult) { return grabMapsResult; } console.log('GrabMaps geocoding failed, falling back to other providers'); } // Try Google Maps if API key is available if (googleMapsApiKey) { console.log('Attempting to geocode with Google Maps'); const googleResult = await geocodeWithGoogleMaps(enhancedQuery, query, country, googleMapsApiKey); if (googleResult) { return googleResult; } console.log('Google Maps geocoding failed, falling back to Nominatim'); } // Fall back to Nominatim (always available as open source solution) console.log('Attempting to geocode with Nominatim'); return await geocodeWithNominatim(enhancedQuery, query, country); } catch (error) { console.error('Geocoding error:', error); return null; } } /** * Geocode using Google Maps API */ async function geocodeWithGoogleMaps(enhancedQuery: string, originalQuery: string, country: string, apiKey: string): Promise<{ lat: number; lon: number } | null> { // Build URL with parameters for Google Maps API const params = new URLSearchParams({ address: enhancedQuery, components: `country:${country}`, key: apiKey }); // Make request to Google Maps Geocoding API console.log(`Geocoding with Google Maps API: "${enhancedQuery}"`); const response = await axios.get(`${GOOGLE_MAPS_GEOCODING_API}?${params.toString()}`); // Check if we got any results if (response.data && response.data.status === 'OK' && response.data.results && response.data.results.length > 0) { const result = response.data.results[0]; const location = result.geometry.location; console.log(`Google Maps found location: ${result.formatted_address}`); return { lat: location.lat, lon: location.lng }; } else { console.log(`Google Maps API returned status: ${response.data.status}`); } // If enhanced query failed and it was different from original, try the original if (enhancedQuery !== originalQuery) { console.log(`Enhanced query failed, trying original query: ${originalQuery}`); const originalParams = new URLSearchParams({ address: originalQuery, components: `country:${country}`, key: apiKey }); const originalResponse = await axios.get(`${GOOGLE_MAPS_GEOCODING_API}?${originalParams.toString()}`); if (originalResponse.data && originalResponse.data.status === 'OK' && originalResponse.data.results && originalResponse.data.results.length > 0) { const result = originalResponse.data.results[0]; const location = result.geometry.location; console.log(`Google Maps found location with original query: ${result.formatted_address}`); return { lat: location.lat, lon: location.lng }; } else { console.log(`Google Maps API returned status for original query: ${originalResponse.data.status}`); } } return null; } /** * Geocode using GrabMaps API via AWS Location Service * * Note: This requires valid AWS credentials with permissions to access AWS Location Service. * If the credentials are invalid or missing, the function will return null and log an error. * * Prerequisites for using this function: * 1. Valid AWS Access Key ID and Secret Access Key with Location Service permissions * 2. A Place Index created in AWS Location Service with GrabMaps as the data provider * 3. GrabMaps API key * 4. Correct AWS region configuration (ap-southeast-5 for Malaysia) * * @param enhancedQuery Enhanced query with additional context * @param originalQuery Original query without enhancement * @param country Country code (e.g., 'my' for Malaysia) * @param apiKey GrabMaps API key * @returns Coordinates or null if geocoding failed */ async function geocodeWithGrabMaps(enhancedQuery: string, originalQuery: string, country: string, apiKey: string): Promise<{ lat: number; lon: number } | null> { console.log(`Attempting to geocode with GrabMaps via AWS Location Service: "${enhancedQuery}"`); try { // Check for required AWS credentials const accessKeyId = process.env.AWS_ACCESS_KEY_ID; const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; const awsRegion = process.env.AWS_REGION || 'ap-southeast-5'; const grabMapsApiKey = process.env.GRABMAPS_API_KEY || apiKey; if (!accessKeyId) { console.error('AWS Access Key ID not found in environment variables'); return null; } if (!secretAccessKey) { console.error('AWS Secret Access Key not found in environment variables'); return null; } if (!grabMapsApiKey) { console.error('GrabMaps API key not found in environment variables'); return null; } if (!awsRegion) { console.error('AWS Region not found in environment variables, using default ap-southeast-5'); // We don't return null here as we have a default value } // Create a new AWS Location Service client const client = new LocationClient({ region: awsRegion, // Use region from env vars or default to Singapore credentials: { accessKeyId, secretAccessKey } }); console.log(`Using AWS region: ${awsRegion}`); console.log('AWS Location Service client created. Attempting to geocode...'); // Convert 2-letter country code to 3-letter code for AWS Location Service // AWS Location Service requires 3-letter ISO country codes const countryCode2 = country.toLowerCase(); let countryCode3 = 'MYS'; // Default to Malaysia // Map of 2-letter to 3-letter country codes for Southeast Asia const countryCodes: Record<string, string> = { 'my': 'MYS', // Malaysia 'sg': 'SGP', // Singapore 'id': 'IDN', // Indonesia 'th': 'THA', // Thailand 'ph': 'PHL', // Philippines 'vn': 'VNM', // Vietnam 'mm': 'MMR', // Myanmar 'la': 'LAO', // Laos 'kh': 'KHM', // Cambodia 'bn': 'BRN', // Brunei 'tl': 'TLS' // Timor-Leste }; if (countryCode2 in countryCodes) { countryCode3 = countryCodes[countryCode2 as keyof typeof countryCodes]; } console.log(`Using 3-letter country code: ${countryCode3}`); // Create the search command const command = new SearchPlaceIndexForTextCommand({ IndexName: 'explore.place.Grab', // The name of your Place Index with GrabMaps data provider Text: enhancedQuery, BiasPosition: [101.6942371, 3.1516964], // Bias towards KL, Malaysia FilterCountries: [countryCode3], // Filter by country MaxResults: 1 }); // Send the command const response = await client.send(command); // Process the response if (response.Results && response.Results.length > 0 && response.Results[0].Place?.Geometry?.Point) { const point = response.Results[0].Place.Geometry.Point; const result = { lat: point[1], // AWS returns [longitude, latitude] lon: point[0] }; console.log(`\u2705 GrabMaps geocoding successful: ${JSON.stringify(result)}`); console.log(`Location: ${response.Results[0].Place.Label}`); return result; } console.log('No results found with GrabMaps via AWS Location Service'); return null; } catch (error) { console.error('Error geocoding with GrabMaps via AWS Location Service:', error); // Check for specific AWS errors if (error && typeof error === 'object' && 'name' in error) { const awsError = error as { name: string }; if (awsError.name === 'UnrecognizedClientException') { console.error('AWS authentication failed. Please check your AWS credentials.'); } else if (awsError.name === 'ValidationException') { console.error('AWS Location Service validation error. Please check your request parameters.'); } else if (awsError.name === 'ResourceNotFoundException') { console.error('Place Index not found. Please check if "explore.place.Grab" exists in your AWS account.'); } } return null; } } /** * Geocode using Nominatim API (OpenStreetMap) */ async function geocodeWithNominatim(enhancedQuery: string, originalQuery: string, country: string): Promise<{ lat: number; lon: number } | null> { // Build URL with parameters for Nominatim const params = new URLSearchParams({ q: enhancedQuery, format: 'json', limit: '1', countrycodes: country, }); // Make request to Nominatim API console.log(`Geocoding with Nominatim API: "${enhancedQuery}"`); const response = await axios.get(`${NOMINATIM_API}?${params.toString()}`, { headers: { 'User-Agent': 'Malaysia-Open-Data-MCP-Server/1.0', }, }); // Check if we got any results if (response.data && response.data.length > 0) { const result = response.data[0]; console.log(`Nominatim found location: ${result.display_name}`); return { lat: parseFloat(result.lat), lon: parseFloat(result.lon), }; } // If enhanced query failed and it was different from original, try the original if (enhancedQuery !== originalQuery) { console.log(`Enhanced query failed, trying original query with Nominatim: ${originalQuery}`); const originalParams = new URLSearchParams({ q: originalQuery, format: 'json', limit: '1', countrycodes: country, }); const originalResponse = await axios.get(`${NOMINATIM_API}?${originalParams.toString()}`, { headers: { 'User-Agent': 'Malaysia-Open-Data-MCP-Server/1.0', }, }); if (originalResponse.data && originalResponse.data.length > 0) { const result = originalResponse.data[0]; console.log(`Nominatim found location with original query: ${result.display_name}`); return { lat: parseFloat(result.lat), lon: parseFloat(result.lon), }; } } return null; } /** * Calculate the Haversine distance between two points in kilometers * @param lat1 Latitude of point 1 * @param lon1 Longitude of point 1 * @param lat2 Latitude of point 2 * @param lon2 Longitude of point 2 * @returns Distance in kilometers */ function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { // Convert latitude and longitude from degrees to radians const toRadians = (degrees: number) => degrees * Math.PI / 180; const dLat = toRadians(lat2 - lat1); const dLon = toRadians(lon2 - lon1); lat1 = toRadians(lat1); lat2 = toRadians(lat2); // Haversine formula const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); // Earth's radius in kilometers const R = 6371; // Return distance in kilometers return R * c; } /** * Register GTFS tools with the MCP server * @param server MCP server instance */ export function registerGtfsTools(server: McpServer) { // Parse GTFS Static data server.tool( prefixToolName('parse_gtfs_static'), 'Parse GTFS Static data for a specific transport provider. IMPORTANT: For transit queries like "Show me routes from Rapid Penang", use get_transit_routes directly with the provider name. This is a low-level tool - prefer using get_transit_routes or get_transit_stops for most user queries.', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), force_refresh: z.boolean().optional().describe('Force refresh the cache'), }, async ({ provider, category, force_refresh = false }) => { try { // Normalize provider and category const normalized = normalizeProviderAndCategory(provider, category); // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Use normalized values const normalizedProvider = normalized.provider; const normalizedCategory = normalized.category; // Build cache key const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`; // Check cache if not forcing refresh if (!force_refresh && gtfsCache.static.has(cacheKey)) { const cached = gtfsCache.static.get(cacheKey)!; // Return cached data if not expired if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) { return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: 'Successfully retrieved GTFS static data from cache', data: cached.data, cached: true, timestamp: cached.timestamp, }, null, 2), }, ], }; } } // Build URL let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}`; if (category) { url += `?category=${category}`; } // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse GTFS data const gtfsData = await parseGtfsStaticZip(Buffer.from(response.data)); // Cache the result gtfsCache.static.set(cacheKey, { data: gtfsData, timestamp: Date.now(), }); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully parsed GTFS static data for provider: ${provider}${category ? `, category: ${category}` : ''}`, data: gtfsData, cached: false, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to parse GTFS static data', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`, response_data: responseData, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required." }, null, 2), }, ], }; } } ); // Parse GTFS Realtime data server.tool( prefixToolName('parse_gtfs_realtime'), 'Parse GTFS Realtime data for a specific transport provider. IMPORTANT: For transit queries like "Show me bus locations from Rapid Penang", use this tool directly with the provider name. Common names like "rapid penang", "rapid kuantan", or "mybas johor" are automatically mapped to the correct provider-category pairs.', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), force_refresh: z.boolean().optional().describe('Force refresh the cache'), }, async ({ provider, category, force_refresh = false }) => { try { // Normalize provider and category const normalized = normalizeProviderAndCategory(provider, category); // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Use normalized values const normalizedProvider = normalized.provider; const normalizedCategory = normalized.category; // Build cache key const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`; // Check cache if not forcing refresh if (!force_refresh && gtfsCache.realtime.has(cacheKey)) { const cached = gtfsCache.realtime.get(cacheKey)!; // Return cached data if not expired if (Date.now() - cached.timestamp < REALTIME_CACHE_EXPIRY) { return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: 'Successfully retrieved GTFS realtime data from cache', data: cached.data, cached: true, timestamp: cached.timestamp, }, null, 2), }, ], }; } } // Build URL let url = `${API_BASE_URL}${GTFS_REALTIME_ENDPOINT}/${provider}/`; if (category) { url += `?category=${category}`; } url += '/'; // Download Protocol Buffer data const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse Protocol Buffer data const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode( new Uint8Array(response.data) ); // Convert to plain JavaScript object const vehiclePositions = feed.entity.map(entity => { if (!entity.vehicle) { return null; } const vehicle = entity.vehicle; return { id: entity.id, vehicle: { trip: vehicle.trip ? { tripId: vehicle.trip.tripId, routeId: vehicle.trip.routeId, directionId: vehicle.trip.directionId, startTime: vehicle.trip.startTime, startDate: vehicle.trip.startDate, scheduleRelationship: vehicle.trip.scheduleRelationship, } : undefined, position: vehicle.position ? { latitude: vehicle.position.latitude, longitude: vehicle.position.longitude, bearing: vehicle.position.bearing, speed: vehicle.position.speed, } : undefined, currentStopSequence: vehicle.currentStopSequence, stopId: vehicle.stopId, currentStatus: vehicle.currentStatus, timestamp: vehicle.timestamp ? new Date(typeof vehicle.timestamp === 'number' ? vehicle.timestamp * 1000 : (vehicle.timestamp as any).low * 1000).toISOString() : undefined, congestionLevel: vehicle.congestionLevel, occupancyStatus: vehicle.occupancyStatus, }, }; }).filter(Boolean); // Cache the result gtfsCache.realtime.set(cacheKey, { data: vehiclePositions, timestamp: Date.now(), }); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully parsed GTFS realtime data for provider: ${provider}${category ? `, category: ${category}` : ''}`, data: vehiclePositions, cached: false, count: vehiclePositions.length, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to parse GTFS realtime data', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, api_url: `${API_BASE_URL}${GTFS_REALTIME_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`, response_data: responseData, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required." }, null, 2), }, ], }; } } ); // Get transit routes server.tool( prefixToolName('get_transit_routes'), 'Get transit routes from GTFS data. IMPORTANT: For transit route queries like "Show me bus routes for Rapid Penang", use this tool directly with the provider name.', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), route_id: z.string().optional().describe('Specific route ID to filter by'), }, async ({ provider, category, route_id }) => { try { // Normalize provider and category const normalized = normalizeProviderAndCategory(provider, category); // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Use normalized values const normalizedProvider = normalized.provider; const normalizedCategory = normalized.category; // Build cache key const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`; // Check if we have cached GTFS data let gtfsData; if (gtfsCache.static.has(cacheKey)) { const cached = gtfsCache.static.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) { gtfsData = cached.data; } } // If no cached data, fetch and parse GTFS data if (!gtfsData) { // Build URL let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`; if (normalizedCategory) { url += `?category=${normalizedCategory}`; } // Trailing slash already added // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse GTFS data gtfsData = await parseGtfsStaticZip(Buffer.from(response.data)); // Cache the result gtfsCache.static.set(cacheKey, { data: gtfsData, timestamp: Date.now(), }); } // Extract routes data const routes = gtfsData.routes || []; // Filter by route_id if provided const filteredRoutes = route_id ? routes.filter((route: { route_id: string }) => route.route_id === route_id) : routes; // Add trips information to each route const routesWithTrips = filteredRoutes.map((route: { route_id: string }) => { const trips = (gtfsData.trips || []) .filter((trip: { route_id: string }) => trip.route_id === route.route_id); return { ...route, trips_count: trips.length, }; }); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully retrieved routes for provider: ${provider}${category ? `, category: ${category}` : ''}`, data: routesWithTrips, count: routesWithTrips.length, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to retrieve transit routes', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`, response_data: responseData, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required." }, null, 2), }, ], }; } } ); // Get transit stops server.tool( prefixToolName('get_transit_stops'), 'Get transit stops from GTFS data. IMPORTANT: For transit stop queries like "Show me bus stops for Rapid Penang", use this tool directly with the provider name. The tool supports common names like "rapid penang", "rapid kuantan", "ktmb", or "mybas johor" which will be automatically mapped to the correct provider and category. No need to use list_transport_agencies first.', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), route_id: z.string().optional().describe('Filter stops by route ID (optional)'), stop_id: z.string().optional().describe('Specific stop ID to retrieve (optional)'), }, async ({ provider, category, route_id, stop_id }) => { try { // Normalize provider and category const normalized = normalizeProviderAndCategory(provider, category); // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Use normalized values const normalizedProvider = normalized.provider; const normalizedCategory = normalized.category; // Build cache key const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`; // Check if we have cached GTFS data let gtfsData; if (gtfsCache.static.has(cacheKey)) { const cached = gtfsCache.static.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) { gtfsData = cached.data; } } // If no cached data, fetch and parse GTFS data if (!gtfsData) { // Build URL let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`; if (normalizedCategory) { url += `?category=${normalizedCategory}`; } // Trailing slash already added // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse GTFS data gtfsData = await parseGtfsStaticZip(Buffer.from(response.data)); // Cache the result gtfsCache.static.set(cacheKey, { data: gtfsData, timestamp: Date.now(), }); } // Extract stops data const stops = gtfsData.stops || []; // Filter by stop_id if provided let filteredStops = stop_id ? stops.filter((stop: { stop_id: string }) => stop.stop_id === stop_id) : stops; // If route_id is provided, filter stops by route if (route_id) { // Get trips for the route const routeTrips = (gtfsData.trips || []) .filter((trip: { route_id: string; trip_id: string }) => trip.route_id === route_id) .map((trip: { trip_id: string }) => trip.trip_id); // Get stop_times for the trips const stopTimes = (gtfsData.stop_times || []) .filter((stopTime: { trip_id: string }) => routeTrips.includes(stopTime.trip_id)); // Get stop_ids from stop_times const stopIds = [...new Set(stopTimes.map((stopTime: { stop_id: string }) => stopTime.stop_id))]; // Filter stops by stop_ids filteredStops = filteredStops.filter((stop: { stop_id: string }) => stopIds.includes(stop.stop_id)); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully retrieved stops for provider: ${provider}${category ? `, category: ${category}` : ''}`, data: filteredStops, count: filteredStops.length, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to retrieve transit stops', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`, response_data: responseData, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required." }, null, 2), }, ], }; } } ); // Get transit arrivals server.tool( prefixToolName('get_transit_arrivals'), 'Get real-time transit arrivals at a specific stop. IMPORTANT: Use this tool directly for queries like "When will the next bus arrive at my stop?" or "Show me arrival times for Rapid Penang buses at stop X".', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana", or common names like "rapid penang")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), stop_id: z.string().describe('ID of the stop to get arrivals for'), route_id: z.string().optional().describe('Optional: filter arrivals by route'), limit: z.number().optional().describe('Maximum number of arrivals to return (default: 10)'), }, async ({ provider, category, stop_id, route_id, limit = 10 }) => { try { // Normalize provider and category const normalized = normalizeProviderAndCategory(provider, category); // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Use normalized values const normalizedProvider = normalized.provider; const normalizedCategory = normalized.category; // Build cache key const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`; // Get static GTFS data (for stop and route information) let gtfsStaticData; if (gtfsCache.static.has(cacheKey)) { const cached = gtfsCache.static.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) { gtfsStaticData = cached.data; } } // If no cached static data, fetch and parse GTFS static data if (!gtfsStaticData) { // Build URL let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`; if (normalizedCategory) { url += `?category=${normalizedCategory}`; } // Trailing slash already added // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse GTFS data gtfsStaticData = await parseGtfsStaticZip(Buffer.from(response.data)); // Cache the result gtfsCache.static.set(cacheKey, { data: gtfsStaticData, timestamp: Date.now(), }); } // Get trip updates data (for real-time arrivals) let tripUpdatesData: any[] = []; if (gtfsCache.tripUpdates.has(cacheKey)) { const cached = gtfsCache.tripUpdates.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < TRIP_UPDATES_CACHE_EXPIRY) { tripUpdatesData = cached.data; } } // If no cached trip updates data, fetch and parse GTFS trip updates if (!tripUpdatesData) { // Build URL let url = `${API_BASE_URL}${GTFS_TRIP_UPDATES_ENDPOINT}/${provider}/`; if (category) { url += `?category=${category}`; } // Trailing slash already added try { // Download Protocol Buffer data const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse Protocol Buffer data const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode( new Uint8Array(response.data) ); // Convert to plain JavaScript object tripUpdatesData = feed.entity.map(entity => { if (!entity.tripUpdate) { return null; } const tripUpdate = entity.tripUpdate; return { id: entity.id, tripUpdate: { trip: tripUpdate.trip ? { tripId: tripUpdate.trip.tripId, routeId: tripUpdate.trip.routeId, directionId: tripUpdate.trip.directionId, startTime: tripUpdate.trip.startTime, startDate: tripUpdate.trip.startDate, scheduleRelationship: tripUpdate.trip.scheduleRelationship, } : undefined, stopTimeUpdate: tripUpdate.stopTimeUpdate ? tripUpdate.stopTimeUpdate.map(update => ({ stopSequence: update.stopSequence, stopId: update.stopId, arrival: update.arrival ? { delay: update.arrival.delay, time: update.arrival.time ? new Date(typeof update.arrival.time === 'number' ? update.arrival.time * 1000 : (update.arrival.time as any).low * 1000).toISOString() : undefined, uncertainty: update.arrival.uncertainty, } : undefined, departure: update.departure ? { delay: update.departure.delay, time: update.departure.time ? new Date(typeof update.departure.time === 'number' ? update.departure.time * 1000 : (update.departure.time as any).low * 1000).toISOString() : undefined, uncertainty: update.departure.uncertainty, } : undefined, scheduleRelationship: update.scheduleRelationship, })) : [], timestamp: tripUpdate.timestamp ? new Date(typeof tripUpdate.timestamp === 'number' ? tripUpdate.timestamp * 1000 : (tripUpdate.timestamp as any).low * 1000).toISOString() : undefined, delay: tripUpdate.delay, } }; }).filter(Boolean); // Cache the result gtfsCache.tripUpdates.set(cacheKey, { data: tripUpdatesData, timestamp: Date.now(), }); } catch (error) { // If trip updates are not available, set to empty array tripUpdatesData = []; // Still cache the empty result to avoid repeated failed requests gtfsCache.tripUpdates.set(cacheKey, { data: tripUpdatesData, timestamp: Date.now(), }); console.error(`Error fetching trip updates for ${provider}${category ? `, category: ${category}` : ''}:`, error); } } // Get stop information const stops = gtfsStaticData.stops || []; const stop = stops.find((s: any) => s.stop_id === stop_id); if (!stop) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: `Stop ID ${stop_id} not found for provider: ${provider}${category ? `, category: ${category}` : ''}`, valid_stop_ids: stops.map((s: any) => s.stop_id).slice(0, 10), total_stops: stops.length, }, null, 2), }, ], }; } // Filter trip updates for the specified stop const arrivalsForStop = []; for (const entity of tripUpdatesData || []) { if (!entity?.tripUpdate?.stopTimeUpdate) continue; // Find updates for this stop const stopUpdates = entity.tripUpdate.stopTimeUpdate.filter((update: any) => update.stopId === stop_id ); if (stopUpdates.length === 0) continue; // Skip if route_id filter is provided and doesn't match if (route_id && entity.tripUpdate.trip?.routeId !== route_id) continue; // Get route information const routes = gtfsStaticData.routes || []; const route = routes.find((r: any) => r.route_id === entity.tripUpdate.trip?.routeId); // Add to arrivals list for (const update of stopUpdates) { arrivalsForStop.push({ trip_id: entity.tripUpdate.trip?.tripId, route_id: entity.tripUpdate.trip?.routeId, route_short_name: route?.route_short_name, route_long_name: route?.route_long_name, direction_id: entity.tripUpdate.trip?.directionId, arrival_time: update.arrival?.time, arrival_delay: update.arrival?.delay, departure_time: update.departure?.time, departure_delay: update.departure?.delay, stop_sequence: update.stopSequence, schedule_relationship: update.scheduleRelationship, }); } } // Sort by arrival time arrivalsForStop.sort((a: any, b: any) => { const timeA = a.arrival_time || a.departure_time || ''; const timeB = b.arrival_time || b.departure_time || ''; return timeA.localeCompare(timeB); }); // Limit results const limitedArrivals = arrivalsForStop.slice(0, limit); // Calculate time until arrival const now = Date.now(); const arrivalsWithCountdown = limitedArrivals.map((arrival: any) => { const arrivalTime = arrival.arrival_time ? new Date(arrival.arrival_time).getTime() : null; const departureTime = arrival.departure_time ? new Date(arrival.departure_time).getTime() : null; const nextTime = arrivalTime || departureTime; let minutesUntil = null; if (nextTime) { minutesUntil = Math.round((nextTime - now) / (60 * 1000)); } return { ...arrival, minutes_until_arrival: minutesUntil, }; }); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully retrieved arrivals for stop: ${stop_id} (${stop.stop_name})`, stop: stop, arrivals: arrivalsWithCountdown, count: arrivalsWithCountdown.length, current_time: new Date().toISOString(), note: arrivalsWithCountdown.length === 0 ? "No upcoming arrivals found for this stop. This could be due to no scheduled service or no real-time data available." : undefined, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to get transit arrivals', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, api_url: `${API_BASE_URL}${GTFS_TRIP_UPDATES_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`, response_data: responseData, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required." }, null, 2), }, ], }; } } ); // Search transit stops by location name server.tool( prefixToolName('search_transit_stops_by_location'), 'Search for transit stops near a named location. IMPORTANT: Use this tool for queries like "Show me bus stops near KLCC" or "What buses stop at KL Sentral?" This tool geocodes the location name to coordinates, then finds nearby stops. CRITICAL: For Rapid KL services, ALWAYS use specific terms in the provider parameter like "rapid kl bus", "rapid rail", "mrt feeder", "lrt", "mrt" instead of using "prasarana" with a separate category parameter. DO NOT use provider="prasarana" with category="rapid-rail-kl" as this causes 404 errors. Instead use provider="rapid rail" or provider="lrt" or provider="mrt" or provider="mrt feeder" or provider="rapid kl bus" without a category parameter.', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana", or common names like "rapid penang")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), location: z.string().describe('Location name to search for (e.g., "KLCC", "KL Sentral", "Penang Airport")'), country: z.string().optional().describe('Country code to limit geocoding results (default: "my" for Malaysia)'), limit: z.number().optional().describe('Maximum number of stops to return (default: 5)'), max_distance: z.number().optional().describe('Maximum distance in kilometers (default: 5)'), include_arrivals: z.boolean().optional().describe('Whether to include upcoming arrivals for each stop (default: true)'), arrivals_limit: z.number().optional().describe('Maximum number of arrivals to include per stop (default: 3)'), }, async ({ provider, category, location, country = 'my', limit = 5, max_distance = 5, include_arrivals = true, arrivals_limit = 3 }) => { // Store normalized values at function scope so they're available in catch block let normalizedProvider = provider; let normalizedCategory = category; try { // If provider looks like prasarana but no category is provided, set a default category // This helps users who don't specify a category in their query if ((provider.toLowerCase() === 'prasarana' || provider.toLowerCase().includes('rapid')) && !category) { // Analyze the location query to determine if it's likely a bus or rail search const locationLower = location.toLowerCase(); // Check if the location contains keywords suggesting rail/LRT/MRT const railKeywords = ['lrt', 'mrt', 'monorail', 'train', 'station', 'rail', 'kelana jaya', 'ampang', 'sri petaling']; const isBusKeyword = locationLower.includes('bus') || locationLower.includes('stop'); const isRailKeyword = railKeywords.some(keyword => locationLower.includes(keyword)); if (isRailKeyword && !isBusKeyword) { // If location suggests rail and not bus, use rail category category = 'rapid-rail-kl'; } else { // Default to bus if not clearly rail or if both bus and rail are mentioned category = 'rapid-bus-kl'; } } // Step 1: Normalize provider and category first const normalized = normalizeProviderAndCategory(provider, category); // Update function scope variables for catch block normalizedProvider = normalized.provider; normalizedCategory = normalized.category; // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Step 2: Geocode the location name to coordinates console.log(`Attempting to geocode location: ${location}`); let coordinates = await geocodeLocation(location, country); // If initial geocoding fails, try with additional context if (!coordinates) { console.log(`Geocoding failed for "${location}", trying with additional context...`); // Try with state/city context for Malaysian locations const locationVariations = [ // Add full country name `${location}, Malaysia`, // Add common Malaysian states if not already in the query ...(!/penang|pulau pinang/i.test(location) ? [`${location}, Penang`, `${location}, Pulau Pinang`] : []), ...(!/selangor/i.test(location) ? [`${location}, Selangor`] : []), ...(!/kuala lumpur|kl/i.test(location) ? [`${location}, Kuala Lumpur`, `${location}, KL`] : []), ...(!/johor/i.test(location) ? [`${location}, Johor`] : []), // Try with common prefixes for condos/apartments ...(!/condo|condominium|apartment|residence|residency|heights|court|villa|garden|park/i.test(location) ? [`${location} Condominium`, `${location} Residence`, `${location} Apartment`] : []) ]; // Try each variation until we get coordinates for (const variation of locationVariations) { console.log(`Trying variation: "${variation}"`); coordinates = await geocodeLocation(variation, country); if (coordinates) { console.log(`Successfully geocoded with variation: "${variation}"`); break; } } } // If all geocoding attempts fail, return error if (!coordinates) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: `Could not geocode location: "${location}". Please try a different location name or provide more specific details.`, location, country, provider_info: { provider: normalizedProvider, category: normalizedCategory, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, suggestion: 'Please try a more specific address with city/state name, or use a nearby landmark.' }, null, 2), }, ], }; } // Use normalized values for provider and category provider = normalized.provider; category = normalized.category; // Build cache key const cacheKey = `${provider}-${category || 'default'}`; // Get static GTFS data let gtfsStaticData; if (gtfsCache.static.has(cacheKey)) { const cached = gtfsCache.static.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) { gtfsStaticData = cached.data; } } // If no cached data, fetch and parse GTFS static data if (!gtfsStaticData) { // Build URL let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`; if (normalizedCategory) { url += `?category=${normalizedCategory}`; } // Trailing slash already added // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse GTFS data gtfsStaticData = await parseGtfsStaticZip(Buffer.from(response.data)); // Cache the result gtfsCache.static.set(cacheKey, { data: gtfsStaticData, timestamp: Date.now(), }); } // Step 3: Extract stops from GTFS data const stops = gtfsStaticData.stops || []; if (stops.length === 0) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: `No stops found for provider: ${provider}${category ? `, category: ${category}` : ''}`, provider: provider, category: category, }, null, 2), }, ], }; } // Step 4: Calculate distances from user location to each stop const stopsWithDistance = stops.map((stop: any) => { // Skip stops without coordinates if (!stop.stop_lat || !stop.stop_lon) { return null; } const distance = haversineDistance( coordinates.lat, coordinates.lon, parseFloat(stop.stop_lat), parseFloat(stop.stop_lon) ); return { ...stop, distance_km: distance, distance_m: Math.round(distance * 1000), }; }).filter(Boolean); // Step 5: Filter stops by max distance and sort by proximity const nearbyStops = stopsWithDistance .filter((stop: any) => stop.distance_km <= max_distance) .sort((a: any, b: any) => a.distance_km - b.distance_km) .slice(0, limit); if (nearbyStops.length === 0) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: `No stops found within ${max_distance} km of "${location}"`, location, coordinates, provider: provider, category: category, max_distance, suggestion: 'Try increasing the max_distance parameter or searching for a different location.', }, null, 2), }, ], }; } // Step 6: If requested, get real-time arrivals for each stop let stopsWithArrivals = nearbyStops; if (include_arrivals) { // Get trip updates data (for real-time arrivals) let tripUpdatesData: any[] = []; if (gtfsCache.tripUpdates.has(cacheKey)) { const cached = gtfsCache.tripUpdates.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < TRIP_UPDATES_CACHE_EXPIRY) { tripUpdatesData = cached.data; } } // If no cached trip updates data, fetch and parse GTFS trip updates if (!tripUpdatesData || tripUpdatesData.length === 0) { // Build URL let url = `${API_BASE_URL}${GTFS_TRIP_UPDATES_ENDPOINT}/${normalizedProvider}/`; if (normalizedCategory) { url += `?category=${normalizedCategory}`; } // Trailing slash already added try { // Download Protocol Buffer data const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse Protocol Buffer data const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode( new Uint8Array(response.data) ); // Convert to plain JavaScript object tripUpdatesData = feed.entity.map(entity => { if (!entity.tripUpdate) { return null; } const tripUpdate = entity.tripUpdate; return { id: entity.id, tripUpdate: { trip: tripUpdate.trip ? { tripId: tripUpdate.trip.tripId, routeId: tripUpdate.trip.routeId, directionId: tripUpdate.trip.directionId, startTime: tripUpdate.trip.startTime, startDate: tripUpdate.trip.startDate, scheduleRelationship: tripUpdate.trip.scheduleRelationship, } : undefined, stopTimeUpdate: tripUpdate.stopTimeUpdate ? tripUpdate.stopTimeUpdate.map(update => ({ stopSequence: update.stopSequence, stopId: update.stopId, arrival: update.arrival ? { delay: update.arrival.delay, time: update.arrival.time ? new Date(typeof update.arrival.time === 'number' ? update.arrival.time * 1000 : (update.arrival.time as any).low * 1000).toISOString() : undefined, uncertainty: update.arrival.uncertainty, } : undefined, departure: update.departure ? { delay: update.departure.delay, time: update.departure.time ? new Date(typeof update.departure.time === 'number' ? update.departure.time * 1000 : (update.departure.time as any).low * 1000).toISOString() : undefined, uncertainty: update.departure.uncertainty, } : undefined, scheduleRelationship: update.scheduleRelationship, })) : [], timestamp: tripUpdate.timestamp ? new Date(typeof tripUpdate.timestamp === 'number' ? tripUpdate.timestamp * 1000 : (tripUpdate.timestamp as any).low * 1000).toISOString() : undefined, delay: tripUpdate.delay, } }; }).filter(Boolean); // Cache the result gtfsCache.tripUpdates.set(cacheKey, { data: tripUpdatesData, timestamp: Date.now(), }); } catch (error) { // If trip updates are not available, set to empty array tripUpdatesData = []; // Still cache the empty result to avoid repeated failed requests gtfsCache.tripUpdates.set(cacheKey, { data: tripUpdatesData, timestamp: Date.now(), }); console.error(`Error fetching trip updates for ${provider}${category ? `, category: ${category}` : ''}:`, error); } } // Get routes information for better display const routes = gtfsStaticData.routes || []; // Add arrivals to each stop stopsWithArrivals = nearbyStops.map((stop: any) => { // Find arrivals for this stop const arrivalsForStop: any[] = []; for (const entity of tripUpdatesData || []) { if (!entity?.tripUpdate?.stopTimeUpdate) continue; // Find updates for this stop const stopUpdates = entity.tripUpdate.stopTimeUpdate.filter((update: any) => update.stopId === stop.stop_id ); if (stopUpdates.length === 0) continue; // Get route information const route = routes.find((r: any) => r.route_id === entity.tripUpdate.trip?.routeId); // Add to arrivals list for (const update of stopUpdates) { arrivalsForStop.push({ trip_id: entity.tripUpdate.trip?.tripId, route_id: entity.tripUpdate.trip?.routeId, route_short_name: route?.route_short_name, route_long_name: route?.route_long_name, direction_id: entity.tripUpdate.trip?.directionId, arrival_time: update.arrival?.time, arrival_delay: update.arrival?.delay, departure_time: update.departure?.time, departure_delay: update.departure?.delay, stop_sequence: update.stopSequence, schedule_relationship: update.scheduleRelationship, }); } } // Sort by arrival time arrivalsForStop.sort((a: any, b: any) => { const timeA = a.arrival_time || a.departure_time || ''; const timeB = b.arrival_time || b.departure_time || ''; return timeA.localeCompare(timeB); }); // Limit results const limitedArrivals = arrivalsForStop.slice(0, arrivals_limit); // Calculate time until arrival const now = Date.now(); const arrivalsWithCountdown = limitedArrivals.map((arrival: any) => { const arrivalTime = arrival.arrival_time ? new Date(arrival.arrival_time).getTime() : null; const departureTime = arrival.departure_time ? new Date(arrival.departure_time).getTime() : null; const nextTime = arrivalTime || departureTime; let minutesUntil = null; if (nextTime) { minutesUntil = Math.round((nextTime - now) / (60 * 1000)); } return { ...arrival, minutes_until_arrival: minutesUntil, }; }); return { ...stop, upcoming_arrivals: arrivalsWithCountdown, has_realtime_data: arrivalsWithCountdown.length > 0, }; }); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Found ${stopsWithArrivals.length} stops near "${location}"`, location, coordinates, provider, category, stops: stopsWithArrivals, count: stopsWithArrivals.length, include_arrivals, current_time: new Date().toISOString(), search_parameters: { max_distance, limit, arrivals_limit: include_arrivals ? arrivals_limit : undefined, }, note: stopsWithArrivals.some((s: any) => s.has_realtime_data) ? undefined : `No real-time arrival data available for these stops. ${REALTIME_DATA_NOTE}`, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; // Try to parse the Buffer data if present let parsedResponseData = responseData; if (responseData && responseData.type === 'Buffer' && Array.isArray(responseData.data)) { try { const buffer = Buffer.from(responseData.data); parsedResponseData = JSON.parse(buffer.toString()); } catch (parseError) { console.error('Error parsing buffer data:', parseError); } } return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to search transit stops by location', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, response_data: parsedResponseData, location, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, suggestion: 'Make sure you are using a valid category for the provider. For Prasarana, use one of: ' + PRASARANA_CATEGORIES.join(', ') + '. For location-based searches, try adding more context like city or state name.' }, null, 2), }, ], }; } } ); // Find nearest transit stops server.tool( prefixToolName('find_nearest_transit_stops'), 'Find the nearest transit stops to a given location. IMPORTANT: Use this tool directly for queries like "Where is the nearest bus stop to my location?" or "How do I get to the nearest Rapid Penang bus stop?"', { provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana", or common names like "rapid penang")'), category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'), latitude: z.number().describe('Latitude of the user\'s location'), longitude: z.number().describe('Longitude of the user\'s location'), limit: z.number().optional().describe('Maximum number of stops to return (default: 5)'), max_distance: z.number().optional().describe('Maximum distance in kilometers (default: 5)'), }, async ({ provider, category, latitude, longitude, limit = 5, max_distance = 5 }) => { try { // Normalize provider and category const normalized = normalizeProviderAndCategory(provider, category); // If there's an error, return it if (normalized.error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: normalized.error, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES, common_names: Object.keys(PROVIDER_MAPPINGS), example: normalized.provider === 'prasarana' ? { provider: 'prasarana', category: 'rapid-rail-kl' } : undefined }, null, 2), }, ], }; } // Use normalized values const normalizedProvider = normalized.provider; const normalizedCategory = normalized.category; // Build cache key const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`; // Check if we have cached GTFS data let gtfsData; if (gtfsCache.static.has(cacheKey)) { const cached = gtfsCache.static.get(cacheKey)!; // Use cached data if not expired if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) { gtfsData = cached.data; } } // If no cached data, fetch and parse GTFS data if (!gtfsData) { // Build URL let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`; if (normalizedCategory) { url += `?category=${normalizedCategory}`; } // Download ZIP file const response = await axios.get(url, { responseType: 'arraybuffer' }); // Parse GTFS data gtfsData = await parseGtfsStaticZip(Buffer.from(response.data)); // Cache the result gtfsCache.static.set(cacheKey, { data: gtfsData, timestamp: Date.now(), }); } // Extract stops data const stops = gtfsData.stops || []; // Calculate distance for each stop const stopsWithDistance = stops.map((stop: any) => { // Skip stops without lat/lon if (!stop.stop_lat || !stop.stop_lon) { return { ...stop, distance: Infinity }; } // Calculate distance using Haversine formula const stopLat = parseFloat(stop.stop_lat); const stopLon = parseFloat(stop.stop_lon); // Haversine formula const R = 6371; // Earth radius in km const dLat = (stopLat - latitude) * Math.PI / 180; const dLon = (stopLon - longitude) * Math.PI / 180; const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(latitude * Math.PI / 180) * Math.cos(stopLat * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); const distance = R * c; // Distance in km return { ...stop, distance }; }); // Filter by max distance and sort by distance const nearestStops = stopsWithDistance .filter((stop: any) => stop.distance <= max_distance) .sort((a: any, b: any) => a.distance - b.distance) .slice(0, limit); // Format distances to be more readable const formattedStops = nearestStops.map((stop: any) => ({ ...stop, distance_km: parseFloat(stop.distance.toFixed(2)), distance_m: parseFloat((stop.distance * 1000).toFixed(0)), })); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully found nearest stops for provider: ${provider}${category ? `, category: ${category}` : ''}`, data: formattedStops, count: formattedStops.length, user_location: { latitude, longitude }, provider_info: { provider, category }, note: REALTIME_DATA_NOTE, }, null, 2), }, ], }; } catch (error) { // Check if it's an axios error with response data const axiosError = error as any; const statusCode = axiosError?.response?.status; const responseData = axiosError?.response?.data; return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: 'Failed to find nearest transit stops', error: error instanceof Error ? error.message : 'Unknown error', status_code: statusCode, api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`, response_data: responseData, provider_info: { provider: provider, category: category, valid_providers: VALID_PROVIDERS, valid_categories: PRASARANA_CATEGORIES }, note: COMBINED_ERROR_NOTE, }, null, 2), }, ], }; } } ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/hithereiamaliff/mcp-datagovmy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server