Skip to main content
Glama
transit.tools.ts59.3 kB
/** * Malaysia Transit Tools * Tools for accessing real-time bus and train information */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import axios, { AxiosRequestConfig } from 'axios'; import { detectAreaFromLocation, getAreaStateMapping } from './geocoding.utils.js'; // MCP Client Identification Header // This identifies requests from Malaysia Transit MCP to the middleware analytics const MCP_CLIENT_HEADERS = { 'X-App-Name': 'Malaysia-Transit-MCP', }; // Get middleware URL from environment const getMiddlewareUrl = (): string => { return process.env.MIDDLEWARE_URL || 'https://malaysiatransit.techmavie.digital'; }; // Create axios instance with MCP client identification const createApiConfig = (params?: Record<string, any>): AxiosRequestConfig => { return { headers: MCP_CLIENT_HEADERS, params, }; }; /** * Register all transit-related tools */ export function registerTransitTools(server: McpServer): void { // ============================================================================ // SERVICE AREA TOOLS // ============================================================================ server.tool( 'list_service_areas', 'List all available transit service areas in Malaysia (e.g., Klang Valley, Penang, Kuantan)', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/areas`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch service areas', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'detect_location_area', 'Automatically detect which transit service area a location belongs to using geocoding. Use this when the user mentions a place name without specifying the area (e.g., "KTM Alor Setar", "Komtar", "KLCC"). IMPORTANT: After detecting the area, use find_nearby_stops_with_arrivals or find_nearby_stops with the location parameter - these tools handle geocoding automatically and are more reliable than using coordinates from this tool.', { location: z.coerce.string().describe('Location name or place (e.g., "KTM Alor Setar", "Komtar", "Pavilion KL")'), }, async ({ location }) => { try { const result = await detectAreaFromLocation(location); if (result) { // Add guidance for AI models on next steps const guidance = result.location.lat === 0 || result.location.lon === 0 ? 'NOTE: Geocoding did not return coordinates. Use find_nearby_stops_with_arrivals or find_nearby_stops with the "location" parameter instead of coordinates.' : 'TIP: You can use find_nearby_stops_with_arrivals with the "location" parameter for a simpler workflow.'; return { content: [ { type: 'text', text: JSON.stringify({ success: true, area: result.area, confidence: result.confidence, location: result.location, source: result.source, message: `Location "${location}" detected in service area: ${result.area}`, nextStepGuidance: guidance, recommendedTools: [ 'find_nearby_stops_with_arrivals - Find nearby stops AND get arrivals in one call', 'find_nearby_stops - Find nearby stops (supports location parameter)', 'search_stops - Search stops by name (auto-geocodes if no match)', ], }, null, 2), }, ], }; } else { // Return available areas if detection fails const areaMapping = getAreaStateMapping(); return { content: [ { type: 'text', text: JSON.stringify({ success: false, message: `Could not automatically detect service area for "${location}". Please specify the area manually.`, availableAreas: areaMapping, }, null, 2), }, ], }; } } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to detect location area', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_area_info', 'Get detailed information about a specific transit service area', { areaId: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley", "kuantan")'), }, async ({ areaId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/areas/${areaId}`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch area info for ${areaId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // STOP TOOLS // ============================================================================ server.tool( 'search_stops', 'Search for bus or train stops by name in a specific area. The middleware will automatically geocode place names (like "Ideal Foresta") and find nearby stops if no exact stop name match is found. IMPORTANT: If you are unsure which area a location belongs to, use detect_location_area first to automatically determine the correct area.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley"). Use detect_location_area if unsure.'), query: z.coerce.string().describe('Search query - can be a stop name (e.g., "Komtar") OR a place name (e.g., "Ideal Foresta"). Place names will be auto-geocoded to find nearby stops.'), }, async ({ area, query }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/stops/search`, createApiConfig({ area, q: query })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to search stops in ${area}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_stop_details', 'Get detailed information about a specific bus or train stop', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), stopId: z.coerce.string().describe('Stop ID from search results'), }, async ({ area, stopId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/stops/${stopId}`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch stop details for ${stopId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_stop_arrivals', 'Get real-time arrival predictions for buses/trains at a specific stop', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), stopId: z.coerce.string().describe('Stop ID from search results'), }, async ({ area, stopId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/stops/${stopId}/arrivals`, createApiConfig({ area })); // Prepare disclaimer message const disclaimer = ` 📊 ABOUT THESE ARRIVAL PREDICTIONS: Our middleware calculates bus arrival times using custom-made algorithms: 1. Shape-Based Distance (Preferred): • Uses actual route geometry from GTFS data • Follows the real road path with curves and turns • Accuracy: ±2-4 minutes compared to Google Maps/Moovit • Indicated by: "calculationMethod": "shape-based", "confidence": "high" or "medium" 2. Straight-Line Distance (Fallback): • Used when shape data unavailable or vehicle position uncertain • Applies 1.4x multiplier to account for road curves • More conservative (may show longer ETAs) • Indicated by: "calculationMethod": "straight-line", "confidence": "low" Key Features: ✅ GPS Speed Validation: Rejects unrealistic speeds (>40 km/h for city buses) ✅ Time-of-Day Adjustments: Rush hour predictions are 40-45% longer ✅ Stop Dwell Time: Adds ~30 seconds per intermediate stop ✅ Ghost Bus Filtering: Removes vehicles that have passed the stop Confidence Levels: • High: Shape-based, vehicle within 50m of route • Medium: Shape-based, vehicle 50-200m from route • Low: Straight-line fallback (no shape data available) Important Notes: ⚠️ Predictions are conservative - buses may arrive earlier than estimated ⚠️ Real-time traffic conditions (accidents, roadworks) are not factored in ⚠️ Operator-provided predictions (TripUpdates) are not yet available from Malaysia's GTFS API (planned for 2026) Accuracy Comparison: • Our predictions: Within 2-4 minutes of Google Maps/Moovit • Conservative bias: Better to arrive early than miss the bus! --- ARRIVAL DATA: `; return { content: [ { type: 'text', text: disclaimer + JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch arrivals for stop ${stopId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'find_nearby_stops', 'Find bus or train stops near a specific location AND get all routes serving those stops. You can provide EITHER coordinates (lat/lon) OR a location name - the middleware will geocode place names automatically.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), lat: z.coerce.number().optional().describe('Latitude coordinate (optional if location is provided)'), lon: z.coerce.number().optional().describe('Longitude coordinate (optional if location is provided)'), location: z.coerce.string().optional().describe('Place name to geocode (e.g., "Ideal Foresta", "KLCC", "Komtar"). Use this instead of lat/lon for convenience.'), radius: z.coerce.number().optional().default(500).describe('Search radius in meters (default: 500)'), }, async ({ area, lat, lon, location, radius }) => { try { const params: any = { area, radius }; // If location is provided, use it for geocoding if (location) { params.location = location; } else if (lat !== undefined && lon !== undefined) { params.lat = lat; params.lon = lon; } else { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Missing location parameters', message: 'Please provide either a location name OR lat/lon coordinates', }, null, 2), }, ], isError: true, }; } // Use /api/stops/nearby/routes endpoint (not /api/stops/nearby which doesn't exist) const response = await axios.get(`${getMiddlewareUrl()}/api/stops/nearby/routes`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to find nearby stops`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'find_nearby_stops_with_arrivals', 'Find bus stops near a location AND get real-time arrival predictions in one call. RECOMMENDED: Use this tool when users ask about nearby bus stops and arrival times together. Accepts a place name (e.g., "Ideal Foresta") - the middleware handles geocoding automatically.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), location: z.coerce.string().optional().describe('Place name to geocode (e.g., "Ideal Foresta", "KLCC", "Komtar")'), lat: z.coerce.number().optional().describe('Latitude coordinate (optional if location is provided)'), lon: z.coerce.number().optional().describe('Longitude coordinate (optional if location is provided)'), radius: z.coerce.number().optional().default(500).describe('Search radius in meters (default: 500)'), routeFilter: z.coerce.string().optional().describe('Filter arrivals by route number (e.g., "302", "101")'), }, async ({ area, location, lat, lon, radius, routeFilter }) => { try { const params: any = { area, radius }; if (location) { params.location = location; } else if (lat !== undefined && lon !== undefined) { params.lat = lat; params.lon = lon; } else { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Missing location parameters', message: 'Please provide either a location name OR lat/lon coordinates', }, null, 2), }, ], isError: true, }; } if (routeFilter) { params.route = routeFilter; } const response = await axios.get(`${getMiddlewareUrl()}/api/stops/nearby/arrivals`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to find nearby stops with arrivals`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'find_nearby_stops_with_routes', 'Find bus stops near a location AND get all routes serving those stops in one call. Accepts a place name (e.g., "Ideal Foresta") - the middleware handles geocoding automatically.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), location: z.coerce.string().optional().describe('Place name to geocode (e.g., "Ideal Foresta", "KLCC", "Komtar")'), lat: z.coerce.number().optional().describe('Latitude coordinate (optional if location is provided)'), lon: z.coerce.number().optional().describe('Longitude coordinate (optional if location is provided)'), radius: z.coerce.number().optional().default(500).describe('Search radius in meters (default: 500)'), }, async ({ area, location, lat, lon, radius }) => { try { const params: any = { area, radius }; if (location) { params.location = location; } else if (lat !== undefined && lon !== undefined) { params.lat = lat; params.lon = lon; } else { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Missing location parameters', message: 'Please provide either a location name OR lat/lon coordinates', }, null, 2), }, ], isError: true, }; } const response = await axios.get(`${getMiddlewareUrl()}/api/stops/nearby/routes`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to find nearby stops with routes`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // ROUTE TOOLS // ============================================================================ server.tool( 'list_routes', 'List all available bus or train routes in a specific area', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), }, async ({ area }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/routes`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch routes for ${area}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_stops', 'Get all stops on a specific route. Use this to find which stops a bus/train route serves. IMPORTANT: First call list_routes to get the correct route_id.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), routeId: z.coerce.string().describe('Route ID from list_routes (e.g., "302", "101")'), }, async ({ area, routeId }) => { try { // Use geometry endpoint which includes stops const response = await axios.get(`${getMiddlewareUrl()}/api/routes/${routeId}/geometry`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch stops for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_details', 'Get detailed information about a specific route including stops and geometry. IMPORTANT: First call list_routes to get the correct route_id.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), routeId: z.coerce.string().describe('Route ID from list_routes (e.g., "302", "101")'), }, async ({ area, routeId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/routes/${routeId}`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch route details for ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_geometry', 'Get the geographic path and stops for a specific route (for map visualization)', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), routeId: z.coerce.string().describe('Route ID from list_routes'), }, async ({ area, routeId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/routes/${routeId}/geometry`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch route geometry for ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // REAL-TIME DATA TOOLS // ============================================================================ server.tool( 'get_live_vehicles', 'Get real-time positions of all buses and trains in a specific area', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), type: z.enum(['bus', 'rail']).optional().describe('Filter by transit type (optional)'), }, async ({ area, type }) => { try { const params: any = { area }; if (type) { params.type = type; } const response = await axios.get(`${getMiddlewareUrl()}/api/realtime`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch live vehicles for ${area}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_provider_status', 'Check the operational status of transit providers in a specific area', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley")'), }, async ({ area }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/areas/${area}/providers/status`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch provider status for ${area}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // SCHEDULE TOOLS (NEW) // ============================================================================ // Areas that support schedule data const SCHEDULE_SUPPORTED_AREAS = ['penang', 'ipoh', 'seremban', 'kangar', 'alor-setar', 'kota-bharu', 'kuala-terengganu', 'melaka', 'johor', 'kuching']; server.tool( 'get_route_departures', 'Get the next N departures for a specific route (both directions). Useful for showing upcoming bus/train times. IMPORTANT: Use route_short_name (e.g., "K10", "A32", "R10", "101") NOT the numeric route_id. Works for: penang, ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh", "seremban", "alor-setar")'), routeId: z.coerce.string().describe('Route SHORT NAME (e.g., "101", "K10", "A32") - NOT the numeric route_id. Get this from list_routes route_short_name field.'), count: z.coerce.number().optional().default(5).describe('Number of departures to return (default: 5)'), }, async ({ area, routeId, count }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/schedules/routes/${routeId}/departures`, createApiConfig({ area, count })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch departures for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_next_departure', 'Get the single next departure for a route in a specific direction. Quick way to find when the next bus/train leaves. IMPORTANT: Use route_short_name (e.g., "101", "K10", "A32") NOT numeric route_id. Works for: penang, ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh", "seremban", "alor-setar")'), routeId: z.coerce.string().describe('Route SHORT NAME (e.g., "101", "K10", "A32") - NOT numeric route_id'), direction: z.enum(['outbound', 'inbound', 'loop']).optional().describe('Direction of travel (optional)'), }, async ({ area, routeId, direction }) => { try { const params: any = { area }; if (direction) { params.direction = direction; } const response = await axios.get(`${getMiddlewareUrl()}/api/schedules/routes/${routeId}/next`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch next departure for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_stop_routes', 'Get all routes serving a specific stop with their next departures. Shows which buses/trains stop here and when. Works for: penang, ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh", "seremban", "alor-setar")'), stopId: z.coerce.string().describe('Stop ID from search_stops'), count: z.coerce.number().optional().default(3).describe('Number of departures per route (default: 3)'), }, async ({ area, stopId, count }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/schedules/stops/${stopId}/routes`, createApiConfig({ area, count })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch routes for stop ${stopId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_schedule', 'Get the complete daily schedule for a route. Shows all departure times throughout the day. IMPORTANT: Use route_short_name (e.g., "101", "K10", "A32") NOT numeric route_id. Works for: penang, ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh", "seremban", "alor-setar")'), routeId: z.coerce.string().describe('Route SHORT NAME (e.g., "101", "K10", "A32") - NOT numeric route_id'), }, async ({ area, routeId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/schedules/routes/${routeId}/full`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch schedule for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_origin', 'Get the origin stop name for a route in a specific direction. Useful for showing where the bus/train starts. IMPORTANT: Use route_short_name (e.g., "101", "K10", "A32") NOT numeric route_id. Works for: penang, ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh", "seremban", "alor-setar")'), routeId: z.coerce.string().describe('Route SHORT NAME (e.g., "101", "K10", "A32") - NOT numeric route_id'), direction: z.enum(['outbound', 'inbound']).optional().describe('Direction of travel (optional)'), }, async ({ area, routeId, direction }) => { try { const params: any = { area }; if (direction) { params.direction = direction; } const response = await axios.get(`${getMiddlewareUrl()}/api/schedules/routes/${routeId}/origin`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch origin for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_status', 'Check if a route is currently operating based on its schedule. Shows if buses/trains are running now. IMPORTANT: Use route_short_name (e.g., "101", "K10", "A32") NOT numeric route_id. Works for: penang, ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh", "seremban", "alor-setar")'), routeId: z.coerce.string().describe('Route SHORT NAME (e.g., "101", "K10", "A32") - NOT numeric route_id'), }, async ({ area, routeId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/schedules/routes/${routeId}/status`, createApiConfig({ area })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch status for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // FARE CALCULATOR TOOLS (NEW) // ============================================================================ server.tool( 'get_fare_routes', 'Get all routes available for fare calculation in a specific area. MUST call this FIRST before calculate_fare to get valid route_id values. Supports: ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching, penang.', { area: z.coerce.string().describe('Service area ID - must be: ipoh, seremban, kangar, alor-setar, kota-bharu, kuala-terengganu, melaka, johor, kuching, or penang'), }, async ({ area }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/fare/${area}/routes`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch fare routes for ${area}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_stops_for_fare', 'Get all stops on a route with their distances for fare calculation. MUST call this SECOND (after get_fare_routes) to get valid stop_id values for calculate_fare. Returns stop IDs and names.', { area: z.coerce.string().describe('Service area ID - same as used in get_fare_routes'), routeId: z.coerce.string().describe('The route_id value from get_fare_routes response (e.g., "D62", "A32", "M15CWLMYMK")'), }, async ({ area, routeId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/fare/${area}/route/${routeId}/stops`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch stops for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'calculate_fare', 'Calculate the bus fare between two stops on a route. IMPORTANT: You MUST first call get_fare_routes to get route_id, then get_route_stops_for_fare to get valid stop_id values. Do NOT guess IDs.', { area: z.coerce.string().describe('Service area ID - same as used in previous calls'), routeId: z.coerce.string().describe('The exact route_id from get_fare_routes (NOT route_short_name)'), fromStop: z.coerce.string().describe('The exact stop_id from get_route_stops_for_fare response'), toStop: z.coerce.string().describe('The exact stop_id from get_route_stops_for_fare response'), }, async ({ area, routeId, fromStop, toStop }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/fare/${area}/calculate`, createApiConfig({ routeId, fromStop, toStop })); // Add disclaimer about fare estimates const disclaimer = ` 💰 FARE CALCULATION DISCLAIMER: This fare is an ESTIMATE based on distance traveled along the route. • BAS.MY uses distance-based fares (RM 0.05/km, min RM 0.40) • Rapid Penang uses staged fare structure • Actual fare may vary - please confirm with the bus driver --- FARE DETAILS: `; return { content: [ { type: 'text', text: disclaimer + JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to calculate fare`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'calculate_journey_fare', 'Calculate the total fare for a multi-leg journey with bus transfers. Each leg is a separate fare since BAS.MY does not have integrated transfers.', { area: z.coerce.string().describe('Base service area ID (legs can specify different areas for inter-area journeys)'), legs: z.array(z.object({ routeId: z.coerce.string().describe('Route ID for this leg'), fromStop: z.coerce.string().describe('Origin stop ID'), toStop: z.coerce.string().describe('Destination stop ID'), areaId: z.coerce.string().optional().describe('Area ID for this leg (optional, defaults to base area)'), })).describe('Array of journey legs (max 5)'), }, async ({ area, legs }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/fare/${area}/calculate-journey`, createApiConfig({ legs: JSON.stringify(legs) })); // Add disclaimer about fare estimates const disclaimer = ` 💰 MULTI-LEG JOURNEY FARE DISCLAIMER: This fare is an ESTIMATE based on distance traveled. • Each bus change requires a separate fare payment • BAS.MY does not have integrated transfer discounts • Actual fare may vary - please confirm with each bus driver --- JOURNEY FARE DETAILS: `; return { content: [ { type: 'text', text: disclaimer + JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to calculate journey fare`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_route_directions_for_fare', 'Get available directions for a route when calculating fares. Use this to determine which direction (outbound/inbound) to use for fare calculation.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "ipoh")'), routeId: z.coerce.string().describe('Route ID from get_fare_routes'), }, async ({ area, routeId }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/fare/${area}/route/${routeId}/directions`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch directions for route ${routeId}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // KTM KOMUTER UTARA TOOLS (NEW) // ============================================================================ server.tool( 'get_ktm_komuter_stations', 'Get all 23 KTM Komuter Utara stations (Padang Besar - Butterworth - Ipoh line). Returns station codes, names, and coordinates.', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/komuter/stations`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch KTM Komuter stations', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'calculate_ktm_komuter_fare', 'Calculate KTM Komuter Utara fare between two stations. Use station codes (e.g., "BU" for Butterworth, "IP" for Ipoh, "PB" for Padang Besar).', { from: z.coerce.string().describe('Origin station code (e.g., "BU", "IP", "PB")'), to: z.coerce.string().describe('Destination station code (e.g., "BU", "IP", "PB")'), }, async ({ from, to }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/komuter/fare`, createApiConfig({ from, to })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to calculate KTM Komuter fare', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_ktm_komuter_fare_matrix', 'Get the full KTM Komuter Utara fare matrix showing fares between all station pairs.', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/komuter/fare-matrix`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch KTM Komuter fare matrix', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_ktm_station_departures', 'Get departure times for a specific KTM station. Supports both KTM Komuter Utara and KTM Intercity schedules.', { stationName: z.coerce.string().describe('Station name (e.g., "Butterworth", "Ipoh", "Padang Besar", "Gemas")'), type: z.enum(['ktm-komuter-utara', 'ktm-intercity']).describe('Schedule type: ktm-komuter-utara (Padang Besar-Ipoh) or ktm-intercity (SH/ERT routes)'), }, async ({ stationName, type }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/stations/${encodeURIComponent(stationName)}/departures`, createApiConfig({ type })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch departures for station ${stationName}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_ktm_stations', 'Get all KTM stations for a specific schedule type (Komuter Utara or Intercity).', { type: z.enum(['ktm-komuter-utara', 'ktm-intercity']).describe('Schedule type: ktm-komuter-utara or ktm-intercity'), }, async ({ type }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/stations`, createApiConfig({ type })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch KTM stations', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_ktm_schedules', 'Get full KTM schedule data for a specific schedule type. Returns complete timetable information.', { type: z.enum(['ktm-komuter-utara', 'ktm-intercity']).describe('Schedule type: ktm-komuter-utara or ktm-intercity'), }, async ({ type }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/schedules`, createApiConfig({ type })); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch KTM schedules', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'find_nearby_ktm_stations', 'Find KTM stations near a specific location. You can provide EITHER coordinates (lat/lon) OR a location name - the middleware will geocode place names automatically.', { location: z.coerce.string().optional().describe('Place name to geocode (e.g., "Butterworth", "Ipoh", "Padang Besar"). Use this instead of lat/lon for convenience.'), lat: z.coerce.number().optional().describe('Latitude coordinate (optional if location is provided)'), lon: z.coerce.number().optional().describe('Longitude coordinate (optional if location is provided)'), radius: z.coerce.number().optional().default(10).describe('Search radius in kilometers (default: 10)'), type: z.enum(['ktm-komuter-utara', 'ktm-intercity']).describe('Schedule type: ktm-komuter-utara or ktm-intercity'), }, async ({ location, lat, lon, radius, type }) => { try { const params: any = { radius, type }; if (location) { params.location = location; } else if (lat !== undefined && lon !== undefined) { params.lat = lat; params.lon = lon; } else { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Missing location parameters', message: 'Please provide either a location name OR lat/lon coordinates', }, null, 2), }, ], isError: true, }; } const response = await axios.get(`${getMiddlewareUrl()}/api/ktm/nearby`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to find nearby KTM stations', message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // PENANG FERRY TOOLS (NEW) // ============================================================================ server.tool( 'get_penang_ferry_overview', 'Get Penang Ferry service overview including terminals, operating hours, frequency, and contact information. The ferry operates between Butterworth and George Town.', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ferry/penang`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch Penang Ferry overview', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_penang_ferry_schedule', 'Get full Penang Ferry schedule with departure times. Supports filtering by direction and day type.', { direction: z.enum(['butterworth-georgetown', 'georgetown-butterworth']).optional().describe('Filter by direction (optional)'), day: z.enum(['weekday', 'weekend']).optional().describe('Filter by day type (optional)'), }, async ({ direction, day }) => { try { const params: any = {}; if (direction) params.direction = direction; if (day) params.day = day; const response = await axios.get(`${getMiddlewareUrl()}/api/ferry/penang/schedule`, createApiConfig(params)); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch Penang Ferry schedule', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_penang_ferry_next_departure', 'Get next ferry departures from both Butterworth and George Town terminals in real-time. Shows minutes until departure.', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ferry/penang/next`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch next ferry departures', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_penang_ferry_terminals', 'Get detailed information about Penang Ferry terminals including facilities, connections, parking, and nearby attractions.', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ferry/penang/terminals`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch ferry terminal information', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_penang_ferry_fare', 'Get Penang Ferry fare information, payment methods, and terminal coordinates.', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/ferry/penang/fare`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch Penang Ferry fare information', message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // SYSTEM TOOLS // ============================================================================ server.tool( 'get_system_health', 'Check the health status of the Malaysia Transit middleware service', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/health`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to check system health', message: error.message, middlewareUrl: getMiddlewareUrl(), }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_debug_info', 'Get comprehensive debug information about the middleware service including memory usage and initialized areas', {}, async () => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/debug`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch debug info', message: error.message, }, null, 2), }, ], isError: true, }; } } ); // ============================================================================ // ANALYTICS TOOLS (NEW) // ============================================================================ server.tool( 'get_api_analytics', 'Get API usage analytics and statistics from the middleware. Shows total requests, requests per hour, error rates, and usage by service area. Useful for monitoring API health and usage patterns.', { type: z.enum(['summary', 'endpoints', 'areas', 'cumulative', 'clients']).optional().default('summary').describe('Type of analytics: summary (overview), endpoints (per-endpoint stats), areas (per-area stats), cumulative (all-time totals), clients (app/website usage)'), }, async ({ type }) => { try { let endpoint = '/api/analytics/summary'; switch (type) { case 'endpoints': endpoint = '/api/analytics/endpoints'; break; case 'areas': endpoint = '/api/analytics/areas'; break; case 'cumulative': endpoint = '/api/analytics/cumulative'; break; case 'clients': endpoint = '/api/analytics/clients'; break; default: endpoint = '/api/analytics/summary'; } const response = await axios.get(`${getMiddlewareUrl()}${endpoint}`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to fetch API analytics', message: error.message, }, null, 2), }, ], isError: true, }; } } ); server.tool( 'get_area_analytics', 'Get detailed API usage analytics for a specific service area. Shows which endpoints are most used for that area.', { area: z.coerce.string().describe('Service area ID (e.g., "penang", "klang-valley", "ipoh")'), }, async ({ area }) => { try { const response = await axios.get(`${getMiddlewareUrl()}/api/analytics/areas/${area}`, createApiConfig()); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to fetch analytics for area ${area}`, message: error.message, }, null, 2), }, ], isError: true, }; } } ); }

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-malaysiatransit'

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