Connects Claude to Malaysia's public transit network across 12 cities, pulling real-time bus and train data from Rapid KL, Rapid Penang, and BAS.MY services. You get live arrivals with shape-based predictions, vehicle tracking, route discovery, and fare calculation. It hits the Malaysia Transit Middleware API which wraps GTFS static and realtime feeds from the Malaysia Open Data Portal. The server includes a journey planner that composes multi-modal trips (bus, rail, ferry, walking) and uses Google Maps for location detection to auto-identify which transit area you're querying. Useful when you need to check arrival times, calculate fares, or plan trips without switching to separate transit apps. Covers Klang Valley, Penang, Ipoh, Johor Bahru, and eight other Malaysian cities.
MCP (Model Context Protocol) server for Malaysia's public transit system, providing real-time bus and train information across 10+ cities in Malaysia.
MCP Endpoint: https://mcp.techmavie.digital/malaysiatransit/mcp
Analytics Dashboard: https://mcp.techmavie.digital/malaysiatransit/analytics/dashboard
Data Source: Malaysia Transit Middleware
plan_journey composes bus + rail + ferry + walking legs across Malaysia (Google Directions engine, locally-computed per-leg fares, AA1 cross-border, free CAT routes)places_autocomplete + place_details for resolving Malaysian place names with transit-aware airport overridesget_fare_structures exposes BAS.MY / Penang / free CAT models so agents can explain how a fare is computedThis MCP server acts as a bridge between AI assistants and the Malaysia Transit Middleware API:
AI Assistant (Claude, GPT, etc.)
↓
Malaysia Transit MCP Server (identifies as "Malaysia-Transit-MCP")
↓
Malaysia Transit Middleware API (tracks usage via X-App-Name header)
↓
Malaysia Open Data Portal (GTFS Static & Realtime)
Client Identification: This MCP automatically sends an X-App-Name: Malaysia-Transit-MCP header with every API request, allowing the middleware to track usage from this MCP separately in its analytics dashboard.
Add this configuration to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
{
"mcpServers": {
"malaysia-transit": {
"transport": "streamable-http",
"url": "https://mcp.techmavie.digital/malaysiatransit/mcp"
}
}
}
npx @modelcontextprotocol/inspector
# Select "Streamable HTTP"
# Enter URL: https://mcp.techmavie.digital/malaysiatransit/mcp
# List all available tools
curl -X POST https://mcp.techmavie.digital/malaysiatransit/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Call the hello tool
curl -X POST https://mcp.techmavie.digital/malaysiatransit/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{}}}'
npm install
The MCP server uses environment variables for configuration:
MIDDLEWARE_URL (required): Malaysia Transit Middleware API URL
https://malaysiatransit.techmavie.digitalINTERNAL_AUTH_TOKEN (required for production): Shared secret for middleware API authentication
INTERNAL_APP_AUTH_TOKEN in the middleware's environmentopenssl rand -hex 32GOOGLE_MAPS_API_KEY (optional): Google Maps API key for location detection
ANALYTICS_RESET_KEY (optional): Admin key for POST /analytics/reset
ANALYTICS_IMPORT_KEY (optional): Admin key for POST /analytics/import
To run the MCP server in development mode:
npm run dev
To build the MCP server for deployment:
npm run build
list_service_areasList all available transit areas in Malaysia.
Parameters: None
Returns: List of service areas with their IDs, names, and capabilities.
Example:
const areas = await tools.list_service_areas();
get_area_infoGet detailed information about a specific area.
Parameters:
areaId (string): Service area ID (e.g., "penang", "klang-valley")Example:
const info = await tools.get_area_info({ areaId: "penang" });
detect_location_area ⭐Automatically detect which transit service area a location belongs to using geocoding.
Parameters:
location (string): Location name or place (e.g., "KTM Alor Setar", "Komtar", "KLCC")Returns: Detected area ID, confidence level, and location details.
Example:
const result = await tools.detect_location_area({ location: "KTM Alor Setar" });
// Returns: { area: "alor-setar", confidence: "high" }
search_stopsSearch for stops by name. Use detect_location_area first if unsure about the area.
Parameters:
area (string): Service area IDquery (string): Search query (e.g., "Komtar", "KLCC")Example:
const stops = await tools.search_stops({
area: "penang",
query: "Komtar"
});
get_stop_detailsGet detailed information about a stop.
Parameters:
area (string): Service area IDstopId (string): Stop ID from search resultsget_stop_arrivals ⭐Get real-time arrival predictions at a stop.
Parameters:
area (string): Service area IDstopId (string): Stop ID from search resultsReturns: Includes a comprehensive disclaimer about prediction methodology, followed by arrival data with:
Prediction Methodology:
Example:
const arrivals = await tools.get_stop_arrivals({
area: "penang",
stopId: "stop_123"
});
// Returns disclaimer + arrival data with confidence levels
find_nearby_stopsFind nearby stops and the routes that serve them.
Parameters:
area (string): Service area IDlocation (string, optional): Place name to geocode (e.g., "KLCC", "Komtar")lat (number, optional): Latitude coordinate (required if location not provided)lon (number, optional): Longitude coordinate (required if location not provided)radius (number, optional): Search radius in meters (default: 500)find_nearby_stops_with_arrivals ⭐Find nearby stops and get real-time arrivals in one call.
Parameters:
area (string): Service area IDlocation (string, optional): Place name to geocodelat (number, optional): Latitude coordinate (required if location not provided)lon (number, optional): Longitude coordinate (required if location not provided)radius (number, optional): Search radius in meters (default: 500)routeFilter (string, optional): Filter arrivals by route number (e.g., "302", "101")find_nearby_stops_with_routesFind nearby stops and get all routes serving those stops in one call.
Parameters:
area (string): Service area IDlocation (string, optional): Place name to geocodelat (number, optional): Latitude coordinate (required if location not provided)lon (number, optional): Longitude coordinate (required if location not provided)radius (number, optional): Search radius in meters (default: 500)list_routesList all routes in an area.
Parameters:
area (string): Service area IDget_route_stopsGet all stops served by a specific route.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesget_route_detailsGet detailed route information.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesget_route_geometryGet route path for map visualization.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesget_live_vehicles ⭐Get real-time vehicle positions.
Parameters:
area (string): Service area IDtype (enum, optional): Filter by type ('bus' or 'rail')Example:
const vehicles = await tools.get_live_vehicles({ area: "penang" });
get_provider_statusCheck provider operational status.
Parameters:
area (string): Service area IDget_route_departuresGet the next N departures for a specific route (both directions).
Parameters:
area (string): Service area ID (e.g., "ipoh", "seremban", "penang")routeId (string): Route ID from list_routescount (number, optional): Number of departures to return (default: 5)Example:
const departures = await tools.get_route_departures({
area: "ipoh",
routeId: "A32",
count: 5
});
get_next_departureGet the single next departure for a route in a specific direction.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesdirection (enum, optional): 'outbound', 'inbound', or 'loop'get_stop_routesGet all routes serving a specific stop with their next departures.
Parameters:
area (string): Service area IDstopId (string): Stop ID from search_stopscount (number, optional): Number of departures per route (default: 3)get_route_scheduleGet the complete daily schedule for a route.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesget_route_originGet the origin stop name for a route in a specific direction.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesdirection (enum, optional): 'outbound' or 'inbound'get_route_statusCheck if a route is currently operating based on its schedule.
Parameters:
area (string): Service area IDrouteId (string): Route ID from list_routesget_fare_routesGet all routes available for fare calculation in a specific area.
Parameters:
area (string): Service area ID (e.g., "ipoh", "seremban", "penang", "kangar")Supported Areas: BAS.MY areas (Ipoh, Seremban, Kangar, Alor Setar, Kota Bharu, Kuala Terengganu, Melaka, Johor, Kuching) and Rapid Penang.
get_route_stops_for_fareGet all stops on a route with their distances for fare calculation.
Parameters:
area (string): Service area IDrouteId (string): Route ID from get_fare_routescalculate_fare ⭐Calculate the bus fare between two stops on a route.
Parameters:
area (string): Service area IDrouteId (string): Route ID from get_fare_routesfromStop (string): Origin stop IDtoStop (string): Destination stop IDReturns: Adult, concession, and child fares in MYR with disclaimer.
Example:
const fare = await tools.calculate_fare({
area: "ipoh",
routeId: "A32",
fromStop: "stop_001",
toStop: "stop_015"
});
// Returns: { adult: "1.50", concession: "0.75", child: "FREE" }
calculate_journey_fareCalculate the total fare for a multi-leg journey with bus transfers.
Parameters:
area (string): Base service area IDlegs (array): Array of journey legs (max 5), each with:
routeId (string): Route ID for this legfromStop (string): Origin stop IDtoStop (string): Destination stop IDareaId (string, optional): Area ID for this leg (for inter-area journeys)Note: Each bus change requires a separate fare payment (BAS.MY does not have integrated transfers).
get_route_directions_for_fareGet available directions for a route when calculating fares.
Parameters:
area (string): Service area IDrouteId (string): Route ID from get_fare_routesget_ktm_komuter_stationsGet all 23 KTM Komuter Utara stations (Padang Besar - Butterworth - Ipoh line).
Parameters: None
Returns: Station codes, names, and coordinates.
Example:
const stations = await tools.get_ktm_komuter_stations();
// Returns: [{ code: "PB", name: "Padang Besar", ... }, ...]
calculate_ktm_komuter_fare ⭐Calculate KTM Komuter Utara fare between two stations.
Parameters:
from (string): Origin station code (e.g., "BU" for Butterworth)to (string): Destination station code (e.g., "IP" for Ipoh)Example:
const fare = await tools.calculate_ktm_komuter_fare({
from: "BU",
to: "IP"
});
// Returns: { adult: "12.00", child: "6.00", currency: "MYR" }
get_ktm_komuter_fare_matrixGet the full KTM Komuter Utara fare matrix showing fares between all station pairs.
Parameters: None
get_ktm_station_departures ⭐Get departure times for a specific KTM station.
Parameters:
stationName (string): Station name (e.g., "Butterworth", "Ipoh")type (enum): Schedule type - ktm-komuter-utara or ktm-intercityExample:
const departures = await tools.get_ktm_station_departures({
stationName: "Butterworth",
type: "ktm-komuter-utara"
});
get_ktm_stationsGet all KTM stations for a specific schedule type.
Parameters:
type (enum): ktm-komuter-utara or ktm-intercityget_ktm_schedulesGet full KTM schedule data for a specific schedule type.
Parameters:
type (enum): ktm-komuter-utara or ktm-intercityfind_nearby_ktm_stationsFind KTM stations near a specific location.
Parameters:
lat (number): Latitude coordinatelon (number): Longitude coordinateradius (number, optional): Search radius in kilometers (default: 10)type (enum): ktm-komuter-utara or ktm-intercityget_penang_ferry_overview ⭐Get Penang Ferry service overview including route, operator, and general info.
Parameters: None
get_penang_ferry_scheduleGet the full Penang Ferry schedule with operating hours and frequency.
Parameters: None
get_penang_ferry_next_departureGet the next upcoming Penang Ferry departures from both terminals.
Parameters: None
get_penang_ferry_terminalsGet Penang Ferry terminal information including locations and facilities.
Parameters: None
get_penang_ferry_fareGet Penang Ferry fare information and payment methods.
Parameters: None
Example:
const fare = await tools.get_penang_ferry_fare();
// Returns: { adult: "1.40", child: "0.70", ... }
get_system_healthCheck the health status of the Malaysia Transit middleware service.
Parameters: None
get_debug_infoGet comprehensive debug information about the middleware service.
Parameters: None
get_api_analyticsGet API usage analytics and statistics from the middleware.
Parameters:
type (enum, optional): Type of analytics to retrieve:
summary (default): Overview with requests/hour, error rate, uptimeendpoints: Per-endpoint statisticsareas: Per-service-area statisticscumulative: All-time totalsclients: App/website usage breakdownExample:
// Get summary analytics
const summary = await tools.get_api_analytics({ type: "summary" });
// Get client usage (see which apps use the API)
const clients = await tools.get_api_analytics({ type: "clients" });
get_area_analyticsGet detailed API usage analytics for a specific service area.
Parameters:
area (string): Service area ID (e.g., "penang", "klang-valley")Example:
const penangStats = await tools.get_area_analytics({ area: "penang" });
plan_journey ⭐Plan a multi-modal public transport journey across Malaysia. Composes bus, rail, ferry, and walking legs via Google Directions in transit mode, then computes per-leg fares locally.
Parameters:
originText (string, optional): Place name (e.g., "Komtar", "Penang Airport", "KL Sentral")originLat / originLng (number, optional): Coordinates (use either text OR lat+lng)destinationText / destinationLat / destinationLng: same shape as originmodeBus / modeRail / modeFerry (boolean, optional): Filter transport modeslanguage (string, optional): en, ms, thReturns: Array of journey alternatives, each with summary (duration, walking distance, transfer count, fare estimate with hasMissingFare flag), legs[] (mode, route, from/to stops, times, per-leg fare), and walkOnly flag.
Errors: invalid-origin (400), invalid-destination (400), invalid-modes (400), unsupported-inter-area (400), area-out-of-coverage (400), engine-unavailable (503), routing-failed (503). Surface these messages to the user directly; do not retry recursively.
Example:
const plan = await tools.plan_journey({
originText: "Penang Airport",
destinationText: "Komtar",
});
Beta caveats: ETAs are scheduled times (use get_stop_arrivals for live ETA at boarding stops). Fares may be missing for some legs (hasMissingFare: true).
get_journey_areasList service areas supported by the journey planner, plus engine name and walking/transit config.
Parameters: None
places_autocompleteServer-side Google Places autocomplete proxy for Malaysian place names. Returns ranked suggestions including transit-aware overrides (airports snap to canonical bus stops).
Parameters:
input (string, 2-120 chars): Search inputsessiontoken (string, optional): Reuse the same token across autocomplete + details calls for Google Places billinglanguage (string, optional): en, ms, thUse case: When the user gives an ambiguous place name and you want them to disambiguate before calling plan_journey.
place_detailsResolve a placeId from places_autocomplete into coordinates and a formatted address.
Parameters:
placeId (string): From places_autocomplete (Google place_id or override:* synthetic ID)sessiontoken (string, optional): Same session token used in autocompletelanguage (string, optional)get_fare_structuresGet the fare structure definitions used by the middleware. Returns BAS.MY distance-based pricing, Rapid Penang staged zones, and free Penang CAT routes (CAT, CT13, C13A/B/C, CT14, CT15).
Parameters: None
Use case: Explain to a user how a fare is computed before calling calculate_fare.
get_ktm_intercity_fareLook up KTM Intercity (SH and ERT routes) fare. NOTE: Currently returns HTTP 501 because Intercity fare data is not yet integrated. The response includes pointers to related endpoints and a link to the official KTMB website. Call this tool to surface the gap honestly to the user rather than guessing a fare.
Parameters: None
helloSimple test tool to verify server is working.
// 1. Detect area from location
const areaResult = await tools.detect_location_area({
location: "KTM Alor Setar"
});
// 2. Search for your stop
const stops = await tools.search_stops({
area: areaResult.area,
query: "KTM Alor Setar"
});
// 3. Get real-time arrivals
const arrivals = await tools.get_stop_arrivals({
area: areaResult.area,
stopId: stops[0].id
});
// Returns: "Bus K100(I) arrives in 1 minute, Bus K100(O) in 2 minutes"
// Get all live vehicles in Penang
const vehicles = await tools.get_live_vehicles({
area: "penang"
});
// Filter by bus only
const buses = await tools.get_live_vehicles({
area: "klang-valley",
type: "bus"
});
// List all routes in Klang Valley
const routes = await tools.list_routes({
area: "klang-valley"
});
// Get detailed route information
const routeDetails = await tools.get_route_details({
area: "klang-valley",
routeId: "LRT-KJ"
});
This is the PRIMARY use case. Users want to know when their next bus/train will arrive.
Workflow:
1. User asks: "When is the next bus at Komtar?"
2. AI uses: detect_location_area({ location: "Komtar" })
3. AI uses: search_stops({ area: "penang", query: "Komtar" })
4. AI uses: get_stop_arrivals({ area: "penang", stopId: "..." })
5. AI responds: "Bus T101 arrives in 5 minutes, Bus T201 in 12 minutes"
Users want to track their bus in real-time.
Workflow:
1. User asks: "Where is bus T101 right now?"
2. AI uses: detect_location_area({ location: "Penang" })
3. AI uses: get_live_vehicles({ area: "penang" })
4. AI filters for route T101
5. AI responds: "Bus T101 is currently at [location], heading towards Airport"
When a user mentions a location without specifying the area, use location detection:
// User: "When is the next bus at KTM Alor Setar?"
const areaResult = await tools.detect_location_area({
location: "KTM Alor Setar"
});
// Returns: { area: "alor-setar", confidence: "high" }
Always search for stops/routes before requesting details:
// ✅ CORRECT
const stops = await tools.search_stops({ area: "penang", query: "Komtar" });
const arrivals = await tools.get_stop_arrivals({
area: "penang",
stopId: stops[0].id
});
// ❌ WRONG - Don't guess stop IDs
const arrivals = await tools.get_stop_arrivals({
area: "penang",
stopId: "random_id"
});
Format arrival times in a user-friendly way:
// ✅ GOOD
"Bus T101 arrives in 5 minutes"
"Train LRT-KJ arrives in 2 minutes"
"Next bus: T201 in 12 minutes"
// ❌ BAD
"Arrival time: 2025-01-07T14:30:00Z"
"ETA: 1736258400000"
Present multiple arrivals clearly:
"Upcoming arrivals at Komtar:
• T101 → Airport: 5 minutes
• T201 → Bayan Lepas: 12 minutes
• T102 → Gurney: 18 minutes"
try {
const arrivals = await tools.get_stop_arrivals({ ... });
} catch (error) {
// Check provider status
const status = await tools.get_provider_status({ area: "penang" });
if (status.providers[0].status !== "active") {
"The transit provider is currently unavailable.
Please try again later or check the official transit app."
}
}
list_service_areas and detect_location_area)| Area ID | Name | Providers | Transit Types | Fare Calculator |
|---|---|---|---|---|
klang-valley | Klang Valley | Rapid Rail KL, Rapid Bus KL, MRT Feeder | Bus, Rail | ❌ (Rapid KL fares not modelled) |
penang | Penang | Rapid Penang (incl. free CAT routes — RM 0), Penang Ferry, KTM Komuter Utara | Bus, Ferry, Rail | ✅ |
kuantan | Kuantan | Under Maintenance | Bus | ❌ |
ipoh | Ipoh | BAS.MY Ipoh, KTM Komuter Utara | Bus, Rail | ✅ |
seremban | Seremban | BAS.MY Seremban, KTM Intercity | Bus, Rail | ✅ |
kangar | Kangar | BAS.MY Kangar, KTM Komuter Utara | Bus, Rail | ✅ |
alor-setar | Alor Setar | BAS.MY Alor Setar, KTM Komuter Utara | Bus, Rail | ✅ |
kota-bharu | Kota Bharu | BAS.MY Kota Bharu, KTM Intercity | Bus, Rail | ✅ |
kuala-terengganu | Kuala Terengganu | BAS.MY Kuala Terengganu | Bus | ✅ |
melaka | Melaka | BAS.MY Melaka | Bus | ✅ |
johor | Johor Bahru | BAS.MY Johor Bahru + Bas Muafakat Johor (BMJ — 41 routes) | Bus | ✅ |
kuching | Kuching | BAS.MY Kuching | Bus | ✅ |
kota-kinabalu | Kota Kinabalu | Coming Soon | - | ❌ |
| Service | Route | Stations | Fare Calculator |
|---|---|---|---|
| KTM Komuter Utara | Padang Besar ↔ Butterworth ↔ Ipoh | 23 stations | ✅ |
| KTM Intercity (SH) | JB Sentral ↔ Gemas ↔ Tumpat | Multiple | ❌ (Coming Soon) |
| KTM Intercity (ERT) | JB Sentral ↔ Gemas ↔ Tumpat | Multiple | ❌ (Coming Soon) |
The detect_location_area tool automatically maps common locations to service areas:
| User Says | Area ID |
|---|---|
| Ipoh, Bercham, Tanjung Rambutan, Medan Kidd | ipoh |
| Seremban, Nilai, Port Dickson | seremban |
| George Town, Butterworth, Bayan Lepas, Penang Sentral | penang |
| KLCC, Shah Alam, Putrajaya | klang-valley |
| Kuantan, Pekan, Bandar Indera Mahkota | kuantan |
| Kangar, Arau, Kuala Perlis, Padang Besar | kangar |
| Alor Setar, Sungai Petani, Pendang, Jitra | alor-setar |
| Kota Bharu, Rantau Panjang, Bachok, Machang, Jeli | kota-bharu |
| Kuala Terengganu, Merang, Marang, Setiu | kuala-terengganu |
| Melaka, Tampin, Jasin, Masjid Tanah | melaka |
| Johor Bahru, Iskandar Puteri, Pasir Gudang, Kulai | johor |
| Kuching, Bau, Serian, Bako, Siniawan, Matang | kuching |
The MCP server is deployed at:
https://mcp.techmavie.digital/malaysiatransit/mcphttps://mcp.techmavie.digital/malaysiatransit/healthhttps://mcp.techmavie.digital/malaysiatransit/analytics/dashboardhttps://mcp.techmavie.digital/malaysiatransit/analyticsThe MCP server includes a built-in analytics dashboard that tracks:
The dashboard auto-refreshes every 30 seconds.
To deploy your own instance, see the deployment guide.
# Using Docker
docker compose up -d --build
# Or run directly
npm run build
npm run start:http
This repository includes a GitHub Actions workflow for automatic VPS deployment. When you push to main, the server automatically redeploys.
To set up auto-deployment, add these secrets to your GitHub repository:
VPS_HOST - Your VPS IP addressVPS_USERNAME - SSH username (e.g., root)VPS_PORT - SSH port (e.g., 22)VPS_SSH_KEY - Private SSH key for authenticationGOOGLE_MAPS_API_KEY - Google Maps API key for geocodingINTERNAL_AUTH_TOKEN - Shared auth token (must match middleware's INTERNAL_APP_AUTH_TOKEN)ANALYTICS_RESET_KEY (optional) - Enables /analytics/reset admin endpointANALYTICS_IMPORT_KEY (optional) - Enables /analytics/import admin endpointIf you can't connect to the middleware:
MIDDLEWARE_URL is correctINTERNAL_AUTH_TOKEN matches middleware INTERNAL_APP_AUTH_TOKEN (if middleware auth is enabled)curl https://your-middleware-url/api/areasIf tools return empty data:
get_provider_statuslist_service_areasReal-time data depends on the upstream GTFS providers:
get_provider_status to check provider healthIf location detection returns incorrect results:
GOOGLE_MAPS_API_KEY is set in environment variablesmalaysiatransit-mcp/
├── src/
│ ├── index.ts # Main MCP server entry point
│ ├── http-server.ts # Streamable HTTP server with analytics
│ ├── transit.tools.ts # Transit tool implementations
│ ├── geocoding.utils.ts # Location detection utilities
│ ├── inspector.ts # MCP Inspector entry point
│ └── server.ts # HTTP server for testing
├── deploy/
│ ├── DEPLOYMENT.md # VPS deployment guide
│ └── nginx-mcp.conf # Nginx reverse proxy config
├── .github/
│ └── workflows/
│ └── deploy-vps.yml # GitHub Actions auto-deploy
├── docker-compose.yml # Docker deployment config
├── Dockerfile # Container build config
├── package.json # Project dependencies
├── tsconfig.json # TypeScript configuration
├── .env.sample # Environment variables template
├── README.md # This file
└── LICENSE # MIT License
Contributions are welcome! Please feel free to submit pull requests or open issues.
MIT - See LICENSE file for details.
Made with ❤️ by Aliff
csoai-org/pdf-document-mcp
xt765/mcp-document-converter
io.github.ai-aviate/better-notion
suekou/mcp-notion-server
meterlong/mcp-doc
n24q02m/better-notion-mcp