This is page 3 of 6. Use http://codebase.md/hithereiamaliff/mcp-datagovmy?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .eslintrc.json
├── .github
│ └── workflows
│ └── deploy-vps.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── .smithery
│ └── index.cjs
├── deploy
│ ├── DEPLOYMENT.md
│ └── nginx-mcp.conf
├── docker-compose.yml
├── Dockerfile
├── index.js
├── LICENSE
├── malaysia_open_data_mcp_plan.md
├── mcp-server.js
├── package-lock.json
├── package.json
├── PROMPT.md
├── README.md
├── response.txt
├── scripts
│ ├── build.js
│ ├── catalogue-index.d.ts
│ ├── catalogue-index.js
│ ├── catalogue-index.ts
│ ├── dashboards-index.d.ts
│ ├── dashboards-index.js
│ ├── deploy.js
│ ├── extract-dataset-ids.js
│ ├── extracted-datasets.js
│ ├── index-catalogue-files.cjs
│ ├── index-dashboards.cjs
│ └── update-tool-names.ts
├── smithery.yaml
├── src
│ ├── api
│ │ ├── catalogue.js
│ │ ├── client.js
│ │ ├── dosm.js
│ │ ├── transport.js
│ │ └── weather.js
│ ├── catalogue.tools.ts
│ ├── dashboards.tools.ts
│ ├── datacatalogue.tools.ts
│ ├── dosm.tools.ts
│ ├── firebase-analytics.ts
│ ├── flood.tools.ts
│ ├── gtfs.tools.ts
│ ├── http-server.ts
│ ├── index.cjs
│ ├── index.js
│ ├── index.ts
│ ├── parquet.tools.ts
│ ├── tools
│ │ ├── catalogue.js
│ │ ├── dosm.js
│ │ ├── test.js
│ │ ├── transport.js
│ │ └── weather.js
│ ├── transport.tools.ts
│ ├── types.d.ts
│ ├── unified-search.tools.ts
│ ├── utils
│ │ ├── query-builder.js
│ │ └── tool-naming.ts
│ └── weather.tools.ts
├── TOOLS.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/catalogue.tools.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { z } from 'zod';
3 | import axios from 'axios';
4 | import {
5 | CATALOGUE_INDEX as catalogueIndex,
6 | CATALOGUE_FILTERS as catalogueFilters,
7 | DatasetMetadata,
8 | SiteCategory
9 | } from '../scripts/catalogue-index.js';
10 |
11 | // API Base URL for Malaysia Open Data API
12 | const API_BASE_URL = 'https://api.data.gov.my';
13 |
14 | // Helper functions for searching and filtering the catalogue
15 | const getDatasetById = (id: string): DatasetMetadata | undefined => {
16 | return catalogueIndex.find(d => d.id === id);
17 | };
18 |
19 | const searchDatasets = (query: string): DatasetMetadata[] => {
20 | const lowerCaseQuery = query.toLowerCase();
21 | return catalogueIndex.filter(d =>
22 | d.title_en.toLowerCase().includes(lowerCaseQuery) ||
23 | d.title_ms.toLowerCase().includes(lowerCaseQuery) ||
24 | d.description_en.toLowerCase().includes(lowerCaseQuery) ||
25 | d.description_ms.toLowerCase().includes(lowerCaseQuery)
26 | );
27 | };
28 |
29 | const filterDatasets = (filters: any): DatasetMetadata[] => {
30 | return catalogueIndex.filter(d => {
31 | if (filters.frequency && d.frequency !== filters.frequency) return false;
32 | if (filters.geography && !filters.geography.every((g: string) => d.geography.includes(g))) return false;
33 | if (filters.demography && !filters.demography.every((dem: string) => d.demography.includes(dem))) return false;
34 | if (filters.dataSource && !filters.dataSource.every((ds: string) => d.data_source.includes(ds))) return false;
35 | return true;
36 | });
37 | };
38 |
39 | // Data Catalogue endpoints - correct endpoint for Malaysia Open Data API
40 | const OPENDOSM_ENDPOINT = '/opendosm';
41 |
42 | // Legacy list of known dataset IDs (keeping for backward compatibility)
43 | const KNOWN_DATASETS = [
44 | { id: 'air_pollution', description: 'Monthly Air Pollution' },
45 | { id: 'arc_dosm', description: 'DOSM\'s Advance Release Calendar' },
46 | { id: 'arrivals', description: 'Monthly Arrivals by Nationality & Sex' },
47 | { id: 'arrivals_soe', description: 'Monthly Arrivals by State of Entry, Nationality & Sex' },
48 | { id: 'births', description: 'Daily Live Births' },
49 | { id: 'births_annual', description: 'Annual Live Births' },
50 | { id: 'births_annual_sex_ethnic', description: 'Annual Live Births by Sex & Ethnicity' },
51 | { id: 'births_annual_sex_ethnic_state', description: 'Annual Live Births by State, Sex, & Ethnicity' },
52 | { id: 'births_annual_state', description: 'Annual Live Births by State' },
53 | { id: 'births_district_sex', description: 'Annual Live Births by District & Sex' },
54 | { id: 'blood_donations', description: 'Daily Blood Donations by Blood Group' },
55 | { id: 'blood_donations_state', description: 'Daily Blood Donations by Blood Group & State' },
56 | { id: 'bop_balance', description: 'Balance of Key BOP Components' },
57 | { id: 'cellular_subscribers', description: 'Cellular Subscribers by Plan Type' },
58 | { id: 'completion_school_state', description: 'School Completion Rates by State' },
59 | { id: 'cosmetic_notifications', description: 'Notified Cosmetic Products' },
60 | { id: 'cosmetic_notifications_cancelled', description: 'Cancelled Cosmetic Product Notifications' },
61 | { id: 'cosmetics_manufacturers', description: 'Approved Manufacturers of Cosmetic Products' },
62 | { id: 'covid_cases', description: 'Daily COVID-19 Cases by State' },
63 | { id: 'covid_cases_age', description: 'Daily COVID-19 Cases by Age Group & State' },
64 | { id: 'covid_cases_vaxstatus', description: 'Daily COVID-19 Cases by Vaccination Status & State' },
65 | { id: 'covid_deaths_linelist', description: 'Transactional Records: Deaths due to COVID-19' },
66 | { id: 'cpi_3d', description: 'Monthly CPI by Group (3-digit)' },
67 | { id: 'cpi_4d', description: 'Monthly CPI by Class (4-digit)' },
68 | { id: 'cpi_5d', description: 'Monthly CPI by Subclass (5-digit)' },
69 | { id: 'cpi_annual', description: 'Annual CPI by Division (2-digit)' },
70 | { id: 'cpi_annual_inflation', description: 'Annual CPI Inflation by Division (2-digit)' },
71 | { id: 'cpi_core', description: 'Monthly Core CPI by Division (2-digit)' },
72 | { id: 'cpi_core_inflation', description: 'Monthly Core CPI Inflation by Division (2-digit)' },
73 | { id: 'cpi_headline', description: 'Monthly CPI by Division (2-digit)' },
74 | { id: 'cpi_headline_inflation', description: 'Monthly CPI Inflation by Division (2-digit)' },
75 | { id: 'cpi_lowincome', description: 'Monthly CPI for Low-Income Households' },
76 | { id: 'cpi_state', description: 'Monthly CPI by State & Division (2-digit)' },
77 | { id: 'cpi_state_inflation', description: 'Monthly CPI Inflation by State & Division (2-digit)' },
78 | { id: 'cpi_strata', description: 'Monthly CPI by Strata & Division (2-digit)' },
79 | { id: 'crime_district', description: 'Crimes by District & Crime Type' },
80 | { id: 'crops_district_area', description: 'Crop Area by District' },
81 | { id: 'crops_district_production', description: 'Crop Production by District' },
82 | { id: 'crops_state', description: 'Crop Area and Production by State' },
83 | { id: 'currency_in_circulation', description: 'Monthly Currency in Circulation' },
84 | { id: 'currency_in_circulation_annual', description: 'Annual Currency in Circulation' },
85 | { id: 'deaths', description: 'Annual Deaths' },
86 | { id: 'deaths_district_sex', description: 'Annual Deaths by District & Sex' },
87 | { id: 'deaths_early_childhood', description: 'Annual Early Childhood Deaths' },
88 | { id: 'deaths_early_childhood_sex', description: 'Annual Early Childhood Deaths by Sex' },
89 | { id: 'deaths_early_childhood_state', description: 'Annual Early Childhood Deaths by State' },
90 | { id: 'deaths_early_childhood_state_sex', description: 'Annual Early Childhood Deaths by State & Sex' },
91 | { id: 'deaths_maternal', description: 'Annual Maternal Deaths' },
92 | { id: 'deaths_maternal_state', description: 'Annual Maternal Deaths by State' },
93 | { id: 'deaths_sex_ethnic', description: 'Annual Deaths by Sex & Ethnicity' },
94 | { id: 'deaths_sex_ethnic_state', description: 'Annual Deaths by State, Sex, & Ethnicity' },
95 | { id: 'deaths_state', description: 'Annual Deaths by State' },
96 | { id: 'domains', description: 'Number of Registered .MY Domains' },
97 | { id: 'domains_dnssec', description: 'Number of Registered .MY Domains with DNSSEC' },
98 | { id: 'domains_idn', description: 'Number of Registered Internationalised .MY Domains' },
99 | { id: 'domains_ipv6', description: 'Number of Registered .MY Domains with IPv6 DNS' },
100 | { id: 'economic_indicators', description: 'Malaysian Economic Indicators' },
101 | { id: 'electricity_access', description: 'Households with Access to Electricity' },
102 | { id: 'electricity_consumption', description: 'Monthly Electricity Consumption' },
103 | { id: 'electricity_supply', description: 'Electricity Supply' },
104 | { id: 'employment_sector', description: 'Employment by MSIC Sector and Sex' },
105 | { id: 'enrolment_school_district', description: 'Enrolment in Government Schools by District' },
106 | { id: 'exchangerates', description: 'Exchange Rates' },
107 | { id: 'fdi_flows', description: 'Foreign Direct Investment (FDI) Flows' },
108 | { id: 'federal_budget_moe', description: 'Annual Budget Allocation for the Ministry of Education' },
109 | { id: 'federal_budget_moh', description: 'Annual Budget Allocation for the Ministry of Health' },
110 | { id: 'federal_finance_qtr', description: 'Quarterly Federal Government Finance' },
111 | { id: 'federal_finance_qtr_de', description: 'Quarterly Federal Government Development Expenditure by Function' },
112 | { id: 'federal_finance_qtr_oe', description: 'Quarterly Federal Government Operating Expenditure by Object' },
113 | { id: 'federal_finance_qtr_revenue', description: 'Quarterly Federal Government Revenue' },
114 | { id: 'federal_finance_year', description: 'Annual Federal Government Finance' },
115 | { id: 'federal_finance_year_de', description: 'Annual Federal Government Development Expenditure by Function' },
116 | { id: 'federal_finance_year_oe', description: 'Annual Federal Government Operating Expenditure by Object' },
117 | { id: 'federal_finance_year_revenue', description: 'Annual Federal Government Revenue' },
118 | { id: 'fertility', description: 'TFR and ASFR' },
119 | { id: 'fertility_state', description: 'TFR and ASFR by State' },
120 | { id: 'fish_landings', description: 'Monthly Landings of Marine Fish by State' },
121 | { id: 'forest_reserve', description: 'Area of Permanent Forest Reserves' },
122 | { id: 'forest_reserve_state', description: 'Area of Permanent Forest Reserves by State' },
123 | { id: 'fuelprice', description: 'Price of Petroleum & Diesel' },
124 | { id: 'gdp_annual_nominal_demand', description: 'Annual Nominal GDP by Expenditure Type' },
125 | { id: 'gdp_annual_nominal_demand_granular', description: 'Annual Nominal GDP by Expenditure Subtype' },
126 | { id: 'gdp_annual_nominal_income', description: 'Annual Nominal GDP by Income Component' },
127 | { id: 'gdp_annual_nominal_supply', description: 'Annual Nominal GDP by Economic Sector' },
128 | { id: 'gdp_annual_nominal_supply_granular', description: 'Annual Nominal GDP by Economic Subsector' },
129 | { id: 'gdp_annual_real_demand', description: 'Annual Real GDP by Expenditure Type' },
130 | { id: 'gdp_annual_real_demand_granular', description: 'Annual Real GDP by Expenditure Subtype' },
131 | { id: 'gdp_annual_real_supply', description: 'Annual Real GDP by Economic Sector' },
132 | { id: 'gdp_annual_real_supply_granular', description: 'Annual Real GDP by Economic Subsector' },
133 | { id: 'gdp_district_real_supply', description: 'Annual Real GDP by District & Economic Sector' },
134 | { id: 'gdp_gni_annual_nominal', description: 'Annual Nominal GDP & GNI: 1947 to Present' },
135 | { id: 'gdp_gni_annual_real', description: 'Annual Real GDP & GNI: 1970 to Present' },
136 | { id: 'gdp_lookup', description: 'Lookup Table: GDP' },
137 | { id: 'gdp_qtr_nominal', description: 'Quarterly Nominal GDP' },
138 | { id: 'gdp_qtr_nominal_demand', description: 'Quarterly Nominal GDP by Expenditure Type' },
139 | { id: 'gdp_qtr_nominal_demand_granular', description: 'Quarterly Nominal GDP by Expenditure Subtype' },
140 | { id: 'gdp_qtr_nominal_supply', description: 'Quarterly Nominal GDP by Economic Sector' },
141 | { id: 'gdp_qtr_nominal_supply_granular', description: 'Quarterly Nominal GDP by Economic Subsector' },
142 | { id: 'gdp_qtr_real', description: 'Quarterly Real GDP' },
143 | { id: 'gdp_qtr_real_demand', description: 'Quarterly Real GDP by Expenditure Type' },
144 | { id: 'gdp_qtr_real_demand_granular', description: 'Quarterly Real GDP by Expenditure Subtype' },
145 | { id: 'gdp_qtr_real_sa', description: 'Quarterly Real GDP (Seasonally Adjusted)' },
146 | { id: 'gdp_qtr_real_sa_demand', description: 'Quarterly Real GDP (Seasonally Adjusted) by Expenditure Type' },
147 | { id: 'gdp_qtr_real_sa_supply', description: 'Quarterly Real GDP (Seasonally Adjusted) by Economic Sector' },
148 | { id: 'gdp_qtr_real_supply', description: 'Quarterly Real GDP by Economic Sector' },
149 | { id: 'gdp_qtr_real_supply_granular', description: 'Quarterly Real GDP by Economic Subsector' },
150 | { id: 'gdp_state_real_supply', description: 'Annual Real GDP by State & Economic Sector' },
151 | { id: 'ghg_emissions', description: 'Greenhouse Gas Emissions' },
152 | { id: 'healthcare_staff', description: 'Healthcare Staff by State and Staff Type' },
153 | { id: 'hh_access_amenities', description: 'Access to Basic Amenities by State & District' },
154 | { id: 'hh_income', description: 'Household Income' },
155 | { id: 'hh_income_district', description: 'Household Income by Administrative District' },
156 | { id: 'hh_income_state', description: 'Household Income by State' },
157 | { id: 'hh_inequality', description: 'Income Inequality' },
158 | { id: 'hh_inequality_district', description: 'Income Inequality by District' },
159 | { id: 'hh_inequality_state', description: 'Income Inequality by State' },
160 | { id: 'hh_poverty', description: 'Poverty' },
161 | { id: 'hh_poverty_district', description: 'Poverty by Administrative District' },
162 | { id: 'hh_poverty_state', description: 'Poverty by State' },
163 | { id: 'hh_profile', description: 'Number of Households and Living Quarters' },
164 | { id: 'hh_profile_state', description: 'Number of Households and Living Quarters by State' },
165 | { id: 'hies_district', description: 'Household Income and Expenditure: Administrative Districts' },
166 | { id: 'hies_malaysia_percentile', description: 'Household Income by Percentile' },
167 | { id: 'hies_state', description: 'Household Income and Expenditure: States' },
168 | { id: 'hies_state_percentile', description: 'Household Income by State & Percentile' },
169 | { id: 'hospital_beds', description: 'Hospital Beds by State and Hospital Type' },
170 | { id: 'infant_immunisation', description: 'Infant Immunisation Coverage' },
171 | { id: 'interestrates', description: 'Monthly Interest Rates' },
172 | { id: 'interestrates_annual', description: 'Annual Interest Rates' },
173 | { id: 'iowrt', description: 'Headline Wholesale & Retail Trade' },
174 | { id: 'iowrt_2d', description: 'Wholesale & Retail Trade by Division (2 digit)' },
175 | { id: 'iowrt_3d', description: 'Wholesale & Retail Trade by Group (3 digit)' },
176 | { id: 'ipi', description: 'Industrial Production Index (IPI)' },
177 | { id: 'ipi_1d', description: 'IPI by Section (1 digit)' },
178 | { id: 'ipi_2d', description: 'IPI by Division (2 digit)' },
179 | { id: 'ipi_3d', description: 'IPI by Group (3 digit)' },
180 | { id: 'ipi_5d', description: 'IPI by Item (5 digit)' },
181 | { id: 'ipi_domestic', description: 'IPI for Domestic-Oriented Divisions (2 digit)' },
182 | { id: 'ipi_export', description: 'IPI for Export-Oriented Divisions (2 digit)' },
183 | { id: 'lecturers_uni', description: 'Lecturers in Public Universities by Citizenship & Sex' },
184 | { id: 'lfs_district', description: 'Annual Principal Labour Force Statistics by District' },
185 | { id: 'lfs_month', description: 'Monthly Principal Labour Force Statistics' },
186 | { id: 'lfs_month_duration', description: 'Monthly Unemployment by Duration' },
187 | { id: 'lfs_month_sa', description: 'Monthly Principal Labour Force Statistics, Seasonally Adjusted' },
188 | { id: 'lfs_month_status', description: 'Monthly Employment by Status in Employment' },
189 | { id: 'lfs_month_youth', description: 'Monthly Youth Unemployment' },
190 | { id: 'lfs_qtr', description: 'Quarterly Principal Labour Force Statistics' },
191 | { id: 'lfs_qtr_sru_age', description: 'Quarterly Skills-Related Underemployment by Age' },
192 | { id: 'lfs_qtr_sru_sex', description: 'Quarterly Skills-Related Underemployment by Sex' },
193 | { id: 'lfs_qtr_state', description: 'Quarterly Principal Labour Force Statistics by State' },
194 | { id: 'lfs_qtr_tru_age', description: 'Quarterly Time-Related Underemployment by Age' },
195 | { id: 'lfs_qtr_tru_sex', description: 'Quarterly Time-Related Underemployment by Sex' },
196 | { id: 'lfs_state_sex', description: 'Annual Principal Labour Force Statistics by State & Sex' },
197 | { id: 'lfs_year', description: 'Annual Principal Labour Force Statistics' },
198 | { id: 'lfs_year_sex', description: 'Annual Principal Labour Force Statistics by Sex' },
199 | { id: 'local_authority_sex', description: 'Female Representation in Local Authorities' },
200 | { id: 'lookup_federal_finance', description: 'Lookup Table: Federal Finance' },
201 | { id: 'lookup_item', description: 'PriceCatcher: Item Lookup' },
202 | { id: 'lookup_money_banking', description: 'Lookup Table: Money & Banking' },
203 | { id: 'lookup_premise', description: 'PriceCatcher: Premise Lookup' },
204 | { id: 'lookup_state', description: 'PriceCatcher: State Lookup' },
205 | { id: 'mpr', description: 'Monetary Policy Rate' },
206 | { id: 'msic_lookup', description: 'Lookup Table: MSIC' },
207 | { id: 'pe_bop', description: 'Balance of Payments' },
208 | { id: 'pe_bop_qtr', description: 'Quarterly Balance of Payments' },
209 | { id: 'pe_iip', description: 'International Investment Position' },
210 | { id: 'pe_iip_qtr', description: 'Quarterly International Investment Position' },
211 | { id: 'pe_reserves', description: 'International Reserves' },
212 | { id: 'pms_state', description: 'Manufacturing Statistics by State' },
213 | { id: 'pms_subsector', description: 'Manufacturing Statistics by Subsector' },
214 | { id: 'population_age', description: 'Population by Age Group, Sex and Ethnicity' },
215 | { id: 'population_district', description: 'Population by District, Sex and Ethnicity' },
216 | { id: 'population_state', description: 'Population by State, Sex and Ethnicity' },
217 | { id: 'pricecatcher', description: 'PriceCatcher: Daily Prices' },
218 | { id: 'producer_price_index', description: 'Producer Price Index' },
219 | { id: 'producer_price_index_1d', description: 'Producer Price Index by Section (1 digit)' },
220 | { id: 'producer_price_index_2d', description: 'Producer Price Index by Division (2 digit)' },
221 | { id: 'producer_price_index_3d', description: 'Producer Price Index by Group (3 digit)' },
222 | { id: 'property_commercial_all', description: 'Commercial Property Transactions' },
223 | { id: 'property_commercial_state', description: 'Commercial Property Transactions by State' },
224 | { id: 'property_residences_all', description: 'Residential Property Transactions' },
225 | { id: 'property_residences_state', description: 'Residential Property Transactions by State' },
226 | { id: 'public_expenditure', description: 'Federal Government Expenditure' },
227 | { id: 'public_finance_snapshot', description: 'Snapshot of Public Finance' },
228 | { id: 'public_revenue', description: 'Federal Government Revenue' },
229 | { id: 'school_enrolment_nat', description: 'National School Enrolment by Type of School' },
230 | { id: 'school_enrolment_state', description: 'School Enrolment by State and Type of School' },
231 | { id: 'services_producer_price_index', description: 'Services Producer Price Index' },
232 | { id: 'services_producer_price_index_2d', description: 'Services Producer Price Index by Division (2 digit)' },
233 | { id: 'services_producer_price_index_3d', description: 'Services Producer Price Index by Group (3 digit)' },
234 | { id: 'social_security', description: 'Social Security Protection' },
235 | { id: 'student_enrolment_higher', description: 'Student Enrolment in Higher Education' },
236 | { id: 'student_enrolment_tvt', description: 'Student Enrolment in TVET' },
237 | { id: 'student_enrolment_uni', description: 'Student Enrolment in Public Universities' },
238 | { id: 'tourism_inbound_asean', description: 'Inbound Tourists by Country (ASEAN)' },
239 | { id: 'tourism_inbound_east_asia', description: 'Inbound Tourists by Country (East Asia)' },
240 | { id: 'tourism_inbound_europe', description: 'Inbound Tourists by Country (Europe)' },
241 | { id: 'tourism_inbound_long_haul', description: 'Inbound Tourists by Country (Long Haul)' },
242 | { id: 'tourism_inbound_monthly', description: 'Monthly Inbound Tourists' },
243 | { id: 'tourism_inbound_purpose', description: 'Inbound Tourists by Purpose of Visit' },
244 | { id: 'tourism_inbound_south_asia', description: 'Inbound Tourists by Country (South Asia)' },
245 | { id: 'tourism_inbound_total', description: 'Total Inbound Tourists' },
246 | { id: 'trade_balance', description: 'Balance of Trade' },
247 | { id: 'trade_balance_1d', description: 'Balance of Trade by Section (1 digit)' },
248 | { id: 'trade_balance_2d', description: 'Balance of Trade by Division (2 digit)' },
249 | { id: 'trade_country', description: 'Trade by Country' },
250 | { id: 'trade_export_1d', description: 'Exports by Section (1 digit)' },
251 | { id: 'trade_export_2d', description: 'Exports by Division (2 digit)' },
252 | { id: 'trade_export_3d', description: 'Exports by Group (3 digit)' },
253 | { id: 'trade_export_5d', description: 'Exports by Item (5 digit)' },
254 | { id: 'trade_import_1d', description: 'Imports by Section (1 digit)' },
255 | { id: 'trade_import_2d', description: 'Imports by Division (2 digit)' },
256 | { id: 'trade_import_3d', description: 'Imports by Group (3 digit)' },
257 | { id: 'trade_import_5d', description: 'Imports by Item (5 digit)' },
258 | { id: 'unemployment_rate', description: 'Unemployment Rate' },
259 | { id: 'unemployment_rate_sa', description: 'Unemployment Rate, Seasonally Adjusted' },
260 | { id: 'water_supply_area', description: 'Water Supply Coverage by Area' },
261 | { id: 'water_supply_state', description: 'Water Supply Coverage by State' }
262 | ];
263 | import { prefixToolName } from './utils/tool-naming.js';
264 |
265 | export function registerDataCatalogueTools(server: McpServer) {
266 | // List all datasets with rich metadata
267 | server.tool(
268 | prefixToolName('list_datasets_catalogue'),
269 | 'Lists all datasets from the comprehensive catalogue with rich metadata',
270 | {
271 | limit: z.number().min(1).max(100).optional().describe('Number of results to return (1-100)'),
272 | offset: z.number().min(0).optional().describe('Number of records to skip for pagination'),
273 | },
274 | async ({ limit = 20, offset = 0 }) => {
275 | const paginatedDatasets = catalogueIndex.slice(offset, offset + limit);
276 | const total = catalogueIndex.length;
277 |
278 | return {
279 | content: [
280 | {
281 | type: 'text',
282 | text: JSON.stringify({
283 | message: 'Datasets retrieved from comprehensive catalogue',
284 | total_datasets: total,
285 | showing: `${offset + 1}-${Math.min(offset + limit, total)} of ${total}`,
286 | pagination: {
287 | limit,
288 | offset,
289 | next_offset: offset + limit < total ? offset + limit : null,
290 | previous_offset: offset > 0 ? Math.max(0, offset - limit) : null,
291 | },
292 | datasets: paginatedDatasets,
293 | timestamp: new Date().toISOString()
294 | }, null, 2),
295 | },
296 | ],
297 | };
298 | }
299 | );
300 |
301 | // Search datasets by query
302 | server.tool(
303 | prefixToolName('search_datasets'),
304 | 'Search datasets by keywords across titles, descriptions and metadata',
305 | {
306 | query: z.string().describe('Search query to match against dataset metadata'),
307 | limit: z.number().min(1).max(100).optional().describe('Number of results to return (1-100)'),
308 | },
309 | async ({ query, limit = 20 }) => {
310 | const searchResults = searchDatasets(query);
311 | const limitedResults = searchResults.slice(0, limit);
312 |
313 | return {
314 | content: [
315 | {
316 | type: 'text',
317 | text: JSON.stringify({
318 | message: 'Search results for datasets',
319 | query,
320 | total_matches: searchResults.length,
321 | showing: Math.min(limit, searchResults.length),
322 | datasets: limitedResults,
323 | timestamp: new Date().toISOString()
324 | }, null, 2),
325 | },
326 | ],
327 | };
328 | }
329 | );
330 |
331 | // Filter datasets by various criteria
332 | server.tool(
333 | prefixToolName('filter_datasets'),
334 | 'Filter datasets by category, geography, frequency, demography, data source or year range',
335 | {
336 | category: z.string().optional().describe('Category or subcategory to filter by'),
337 | geography: z.string().optional().describe('Geographic coverage to filter by (e.g., NATIONAL, STATE, DISTRICT)'),
338 | frequency: z.string().optional().describe('Data frequency to filter by (e.g., DAILY, MONTHLY, YEARLY)'),
339 | demography: z.string().optional().describe('Demographic dimension to filter by (e.g., SEX, AGE)'),
340 | dataSource: z.string().optional().describe('Data source agency to filter by (e.g., DOSM, BNM)'),
341 | startYear: z.number().optional().describe('Start year for filtering datasets by time coverage'),
342 | endYear: z.number().optional().describe('End year for filtering datasets by time coverage'),
343 | limit: z.number().min(1).max(100).optional().describe('Number of results to return (1-100)'),
344 | },
345 | async ({ category, geography, frequency, demography, dataSource, startYear, endYear, limit = 20 }) => {
346 | const yearRange = startYear && endYear ? [startYear, endYear] as [number, number] : undefined;
347 |
348 | const filteredDatasets = filterDatasets({
349 | category,
350 | geography,
351 | frequency,
352 | demography,
353 | dataSource,
354 | yearRange
355 | });
356 |
357 | const limitedResults = filteredDatasets.slice(0, limit);
358 |
359 | return {
360 | content: [
361 | {
362 | type: 'text',
363 | text: JSON.stringify({
364 | message: 'Filtered datasets',
365 | filters: {
366 | category,
367 | geography,
368 | frequency,
369 | demography,
370 | dataSource,
371 | year_range: yearRange ? `${yearRange[0]}-${yearRange[1]}` : undefined
372 | },
373 | total_matches: filteredDatasets.length,
374 | showing: Math.min(limit, filteredDatasets.length),
375 | datasets: limitedResults,
376 | timestamp: new Date().toISOString()
377 | }, null, 2),
378 | },
379 | ],
380 | };
381 | }
382 | );
383 |
384 | // Get dataset details by ID from catalogue
385 | server.tool(
386 | prefixToolName('get_dataset_metadata'),
387 | 'Get comprehensive metadata for a dataset by ID from the local catalogue',
388 | {
389 | id: z.string().describe('ID of the dataset to retrieve metadata for'),
390 | },
391 | async ({ id }) => {
392 | const dataset = getDatasetById(id);
393 |
394 | if (!dataset) {
395 | // Try to find similar datasets for suggestion
396 | const similarDatasets = catalogueIndex
397 | .filter((d: DatasetMetadata) => d.id.includes(id) || id.includes(d.id))
398 | .map((d: DatasetMetadata) => ({ id: d.id, title_en: d.title_en }))
399 | .slice(0, 5);
400 |
401 | return {
402 | content: [
403 | {
404 | type: 'text',
405 | text: JSON.stringify({
406 | error: `Dataset with ID "${id}" not found in the catalogue`,
407 | suggested_datasets: similarDatasets.length > 0 ? similarDatasets : undefined,
408 | total_datasets_available: catalogueIndex.length,
409 | note: 'Use list_datasets_catalogue to see all available datasets',
410 | timestamp: new Date().toISOString()
411 | }, null, 2),
412 | },
413 | ],
414 | };
415 | }
416 |
417 | return {
418 | content: [
419 | {
420 | type: 'text',
421 | text: JSON.stringify({
422 | message: 'Dataset metadata retrieved successfully',
423 | dataset,
424 | download_links: {
425 | parquet: dataset.link_parquet || null,
426 | csv: dataset.link_csv || null,
427 | preview: dataset.link_preview || null
428 | },
429 | timestamp: new Date().toISOString()
430 | }, null, 2),
431 | },
432 | ],
433 | };
434 | }
435 | );
436 |
437 | // Get available filter values
438 | server.tool(
439 | prefixToolName('get_catalogue_filters'),
440 | 'Get all available filter values for searching and filtering datasets',
441 | {},
442 | async () => {
443 | return {
444 | content: [
445 | {
446 | type: 'text',
447 | text: JSON.stringify({
448 | message: 'Available filter values for the dataset catalogue',
449 | filters: catalogueFilters,
450 | total_datasets: catalogueIndex.length,
451 | timestamp: new Date().toISOString()
452 | }, null, 2),
453 | },
454 | ],
455 | };
456 | }
457 | );
458 |
459 | // Legacy tool - List known dataset IDs (keeping for backward compatibility)
460 | server.tool(
461 | prefixToolName('list_known_datasets'),
462 | 'Lists known dataset IDs that can be used with the OpenDOSM API',
463 | {},
464 | async () => {
465 | // Convert our rich catalogue to the simple format for backward compatibility
466 | const simpleDatasets = catalogueIndex.map((dataset: DatasetMetadata) => ({
467 | id: dataset.id,
468 | description: dataset.title_en
469 | }));
470 |
471 | return {
472 | content: [
473 | {
474 | type: 'text',
475 | text: JSON.stringify({
476 | message: 'Available dataset IDs for OpenDOSM API',
477 | datasets: simpleDatasets,
478 | note: 'Use these dataset IDs with the get_dataset_details tool, or try the new get_dataset_metadata tool for richer information',
479 | timestamp: new Date().toISOString()
480 | }, null, 2),
481 | },
482 | ],
483 | };
484 | }
485 | );
486 |
487 | // List datasets
488 | server.tool(
489 | prefixToolName('list_datasets'),
490 | 'Lists available datasets in the Malaysia Open Data catalogue',
491 | {
492 | id: z.string().optional().describe('Dataset ID to retrieve (e.g., "cpi_core")'),
493 | limit: z.number().min(1).max(100).optional().describe('Number of results to return (1-100)'),
494 | meta: z.boolean().optional().describe('Whether to return metadata about available datasets'),
495 | },
496 | async ({ id, limit = 10, meta = false }) => {
497 | try {
498 | // If no dataset ID is provided, return the list of known datasets instead
499 | if (!id) {
500 | return {
501 | content: [
502 | {
503 | type: 'text',
504 | text: JSON.stringify({
505 | message: 'The OpenDOSM API requires a specific dataset ID',
506 | note: 'Please use one of the following dataset IDs:',
507 | available_datasets: KNOWN_DATASETS,
508 | example_usage: 'Use list_datasets with id="cpi_core" or get_dataset_details with id="cpi_core"',
509 | timestamp: new Date().toISOString()
510 | }, null, 2),
511 | },
512 | ],
513 | };
514 | }
515 |
516 | const url = `${API_BASE_URL}${OPENDOSM_ENDPOINT}`;
517 | const params: Record<string, any> = { id };
518 |
519 | // Add additional parameters if provided
520 | if (limit) params.limit = limit;
521 | if (meta) params.meta = 1;
522 |
523 | // Setup request headers
524 | const headers = {
525 | 'Content-Type': 'application/json',
526 | 'Accept': 'application/json'
527 | };
528 |
529 | const response = await axios.get(url, { params, headers });
530 |
531 | return {
532 | content: [
533 | {
534 | type: 'text',
535 | text: JSON.stringify({
536 | message: 'Datasets retrieved successfully',
537 | dataset_id: id,
538 | params: params,
539 | endpoint: url,
540 | datasets: response.data,
541 | timestamp: new Date().toISOString()
542 | }, null, 2),
543 | },
544 | ],
545 | };
546 | } catch (error) {
547 | console.error('Error fetching datasets:', error);
548 |
549 | // Check if this might be due to an invalid dataset ID
550 | const knownIds = KNOWN_DATASETS.map(dataset => dataset.id);
551 | const suggestedDatasets = id ?
552 | KNOWN_DATASETS.filter(dataset => dataset.id.includes(id.toLowerCase()) ||
553 | dataset.description.toLowerCase().includes(id.toLowerCase())) :
554 | [];
555 |
556 | return {
557 | content: [
558 | {
559 | type: 'text',
560 | text: JSON.stringify({
561 | error: 'Failed to fetch datasets',
562 | message: error instanceof Error ? error.message : 'Unknown error',
563 | status: axios.isAxiosError(error) ? error.response?.status : undefined,
564 | possible_issue: id && !knownIds.includes(id) ? `Dataset ID "${id}" may not be valid` : undefined,
565 | suggested_datasets: suggestedDatasets.length > 0 ? suggestedDatasets : undefined,
566 | available_datasets: 'Use list_known_datasets tool to see all available dataset IDs',
567 | timestamp: new Date().toISOString()
568 | }, null, 2),
569 | },
570 | ],
571 | };
572 | }
573 | }
574 | );
575 |
576 | // Get dataset details
577 | server.tool(
578 | prefixToolName('get_dataset_details'),
579 | 'Gets detailed information about a specific dataset',
580 | {
581 | id: z.string().describe('ID of the dataset to retrieve (e.g., "cpi_core")'),
582 | limit: z.number().min(1).optional().describe('Maximum number of records to return'),
583 | offset: z.number().min(0).optional().describe('Number of records to skip for pagination'),
584 | },
585 | async ({ id, limit = 10, offset }) => {
586 | try {
587 | // Validate if the dataset ID is known
588 | const knownIds = KNOWN_DATASETS.map(dataset => dataset.id);
589 | if (!knownIds.includes(id)) {
590 | const suggestedDatasets = KNOWN_DATASETS.filter(dataset =>
591 | dataset.id.includes(id.toLowerCase()) ||
592 | dataset.description.toLowerCase().includes(id.toLowerCase())
593 | );
594 |
595 | return {
596 | content: [
597 | {
598 | type: 'text',
599 | text: JSON.stringify({
600 | warning: `Dataset ID "${id}" may not be valid`,
601 | suggested_datasets: suggestedDatasets.length > 0 ? suggestedDatasets : undefined,
602 | available_datasets: KNOWN_DATASETS,
603 | note: 'The dataset ID you provided is not in our known list, but we will try to fetch it anyway.',
604 | timestamp: new Date().toISOString()
605 | }, null, 2),
606 | },
607 | ],
608 | };
609 | }
610 |
611 | const url = `${API_BASE_URL}${OPENDOSM_ENDPOINT}`;
612 | const params: Record<string, any> = { id };
613 |
614 | // Add optional parameters if provided
615 | if (limit) params.limit = limit;
616 | if (offset !== undefined) params.offset = offset;
617 |
618 | // Setup request headers
619 | const headers = {
620 | 'Content-Type': 'application/json',
621 | 'Accept': 'application/json'
622 | };
623 |
624 | const response = await axios.get(url, { params, headers });
625 |
626 | return {
627 | content: [
628 | {
629 | type: 'text',
630 | text: JSON.stringify({
631 | message: 'Dataset details retrieved successfully',
632 | dataset_id: id,
633 | endpoint: url,
634 | details: response.data,
635 | timestamp: new Date().toISOString()
636 | }, null, 2),
637 | },
638 | ],
639 | };
640 | } catch (error) {
641 | console.error('Error fetching dataset details:', error);
642 |
643 | // Check if this might be due to an invalid dataset ID
644 | const knownIds = KNOWN_DATASETS.map(dataset => dataset.id);
645 | const suggestedDatasets = KNOWN_DATASETS.filter(dataset =>
646 | dataset.id.includes(id.toLowerCase()) ||
647 | dataset.description.toLowerCase().includes(id.toLowerCase())
648 | );
649 |
650 | return {
651 | content: [
652 | {
653 | type: 'text',
654 | text: JSON.stringify({
655 | error: 'Failed to fetch dataset details',
656 | dataset_id: id,
657 | message: error instanceof Error ? error.message : 'Unknown error',
658 | status: axios.isAxiosError(error) ? error.response?.status : undefined,
659 | possible_issue: !knownIds.includes(id) ? `Dataset ID "${id}" may not be valid` : undefined,
660 | suggested_datasets: suggestedDatasets.length > 0 ? suggestedDatasets : undefined,
661 | available_datasets: 'Use list_known_datasets tool to see all available dataset IDs',
662 | timestamp: new Date().toISOString()
663 | }, null, 2),
664 | },
665 | ],
666 | };
667 | }
668 | }
669 | );
670 |
671 | // List dataset categories from our comprehensive filters
672 | server.tool(
673 | prefixToolName('list_dataset_categories'),
674 | 'Lists all available dataset categories from the catalogue',
675 | {},
676 | async () => {
677 | try {
678 | // Group datasets by category
679 | const categoryCounts: Record<string, number> = {};
680 |
681 | // Count datasets in each category
682 | catalogueIndex.forEach((dataset: DatasetMetadata) => {
683 | dataset.site_category.forEach((cat: SiteCategory) => {
684 | const category = cat.category_en;
685 | categoryCounts[category] = (categoryCounts[category] || 0) + 1;
686 | });
687 | });
688 |
689 | // Format as array of objects with category and count
690 | const categoriesWithCounts = Object.entries(categoryCounts).map(([category, count]) => ({
691 | category,
692 | dataset_count: count
693 | })).sort((a, b) => a.category.localeCompare(b.category));
694 |
695 | return {
696 | content: [
697 | {
698 | type: 'text',
699 | text: JSON.stringify({
700 | message: 'Dataset categories available',
701 | categories: categoriesWithCounts,
702 | total_categories: categoriesWithCounts.length,
703 | note: 'For specific datasets, use the filter_datasets tool with the category parameter',
704 | timestamp: new Date().toISOString()
705 | }, null, 2),
706 | },
707 | ],
708 | };
709 | } catch (error) {
710 | console.error('Error generating dataset categories:', error);
711 |
712 | return {
713 | content: [
714 | {
715 | type: 'text',
716 | text: JSON.stringify({
717 | error: 'Failed to generate dataset categories',
718 | message: error instanceof Error ? error.message : 'Unknown error',
719 | alternative: 'Please use list_datasets_catalogue to see available datasets',
720 | timestamp: new Date().toISOString()
721 | }, null, 2),
722 | },
723 | ],
724 | };
725 | }
726 | }
727 | );
728 |
729 | // List dataset agencies from our comprehensive filters
730 | server.tool(
731 | prefixToolName('list_dataset_agencies'),
732 | 'Lists all agencies (data sources) providing datasets',
733 | {},
734 | async () => {
735 | try {
736 | // Count datasets from each data source
737 | const agencyCounts: Record<string, number> = {};
738 |
739 | catalogueIndex.forEach((dataset: DatasetMetadata) => {
740 | dataset.data_source.forEach((source: string) => {
741 | agencyCounts[source] = (agencyCounts[source] || 0) + 1;
742 | });
743 | });
744 |
745 | // Format as array of objects with agency and count
746 | const agenciesWithCounts = Object.entries(agencyCounts).map(([agency, count]) => ({
747 | agency,
748 | dataset_count: count
749 | })).sort((a, b) => b.dataset_count - a.dataset_count);
750 |
751 | return {
752 | content: [
753 | {
754 | type: 'text',
755 | text: JSON.stringify({
756 | message: 'Dataset agencies available',
757 | agencies: agenciesWithCounts,
758 | total_agencies: agenciesWithCounts.length,
759 | note: 'For specific datasets, use the filter_datasets tool with the dataSource parameter',
760 | timestamp: new Date().toISOString()
761 | }, null, 2),
762 | },
763 | ],
764 | };
765 | } catch (error) {
766 | console.error('Error generating dataset agencies:', error);
767 |
768 | return {
769 | content: [
770 | {
771 | type: 'text',
772 | text: JSON.stringify({
773 | error: 'Failed to generate dataset agencies',
774 | message: error instanceof Error ? error.message : 'Unknown error',
775 | alternative: 'Please use list_datasets_catalogue to see available datasets',
776 | timestamp: new Date().toISOString()
777 | }, null, 2),
778 | },
779 | ],
780 | };
781 | }
782 | }
783 | );
784 |
785 | // The following tools are kept for backward compatibility with the OpenDOSM API
786 | // They make direct API calls rather than using our local catalogue
787 | }
788 |
```
--------------------------------------------------------------------------------
/src/gtfs.tools.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { z } from 'zod';
3 | import axios from 'axios';
4 | import * as GtfsRealtimeBindings from 'gtfs-realtime-bindings';
5 | import JSZip from 'jszip';
6 | import csvParser from 'csv-parser';
7 | import { Readable } from 'stream';
8 | import { prefixToolName } from './utils/tool-naming.js';
9 | import { LocationClient, SearchPlaceIndexForTextCommand } from '@aws-sdk/client-location';
10 |
11 | // API Base URL for Malaysia Open Data API
12 | const API_BASE_URL = 'https://api.data.gov.my';
13 |
14 | // GTFS endpoints
15 | const GTFS_STATIC_ENDPOINT = '/gtfs-static';
16 | const GTFS_REALTIME_ENDPOINT = '/gtfs-realtime/vehicle-position';
17 | const GTFS_TRIP_UPDATES_ENDPOINT = '/gtfs-realtime/trip-update';
18 |
19 | // Real-time data availability note
20 | 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).";
21 |
22 | // Error note for 404 errors
23 | 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.";
24 |
25 | // Combined note for error responses
26 | const COMBINED_ERROR_NOTE = `${ERROR_404_NOTE} ${REALTIME_DATA_NOTE}`;
27 |
28 | // Geocoding APIs
29 | const GOOGLE_MAPS_GEOCODING_API = 'https://maps.googleapis.com/maps/api/geocode/json';
30 | const NOMINATIM_API = 'https://nominatim.openstreetmap.org/search';
31 |
32 | // Google Maps API Key from environment variable
33 | // We'll determine this dynamically in the geocodeLocation function to ensure
34 | // it picks up any changes made after server initialization
35 | let googleMapsApiKeyLastChecked = 0;
36 | let cachedGoogleMapsApiKey = '';
37 |
38 | function getGoogleMapsApiKey(): string {
39 | // Only check once per minute to avoid excessive environment variable lookups
40 | const now = Date.now();
41 | if (now - googleMapsApiKeyLastChecked > 60000) {
42 | cachedGoogleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY || '';
43 | googleMapsApiKeyLastChecked = now;
44 |
45 | if (!cachedGoogleMapsApiKey) {
46 | console.log('No Google Maps API key found. Using Nominatim API for geocoding as fallback.');
47 | } else {
48 | console.log('Using Google Maps API for geocoding.');
49 | }
50 | }
51 |
52 | return cachedGoogleMapsApiKey;
53 | }
54 |
55 | // Valid providers and categories
56 | const VALID_PROVIDERS = ['mybas-johor', 'ktmb', 'prasarana'];
57 |
58 | // Valid categories for Prasarana
59 | const PRASARANA_CATEGORIES = [
60 | 'rapid-bus-penang',
61 | 'rapid-bus-kuantan',
62 | 'rapid-bus-mrtfeeder',
63 | 'rapid-rail-kl',
64 | 'rapid-bus-kl'
65 | ];
66 |
67 | // Common name mappings to help with user queries
68 | const PROVIDER_MAPPINGS: Record<string, { provider: string; category?: string }> = {
69 | // Direct provider mappings
70 | 'mybas': { provider: 'mybas-johor' },
71 | 'mybas johor': { provider: 'mybas-johor' },
72 | 'mybas johor bahru': { provider: 'mybas-johor' },
73 | 'ktmb': { provider: 'ktmb' },
74 | 'ktm': { provider: 'ktmb' },
75 | 'keretapi tanah melayu': { provider: 'ktmb' },
76 | 'keretapi tanah melayu berhad': { provider: 'ktmb' },
77 | 'prasarana': { provider: 'prasarana', category: 'rapid-rail-kl' },
78 |
79 | // Prasarana services (mapped to provider + category)
80 | 'rapid rail': { provider: 'prasarana', category: 'rapid-rail-kl' },
81 | 'rapid rail kl': { provider: 'prasarana', category: 'rapid-rail-kl' },
82 | 'rapid kl rail': { provider: 'prasarana', category: 'rapid-rail-kl' },
83 | 'rapid-rail': { provider: 'prasarana', category: 'rapid-rail-kl' },
84 | 'rapid-rail-kl': { provider: 'prasarana', category: 'rapid-rail-kl' },
85 | 'mrt': { provider: 'prasarana', category: 'rapid-rail-kl' },
86 | 'lrt': { provider: 'prasarana', category: 'rapid-rail-kl' },
87 | 'monorail': { provider: 'prasarana', category: 'rapid-rail-kl' },
88 | 'monorel': { provider: 'prasarana', category: 'rapid-rail-kl' },
89 | 'kl mrt': { provider: 'prasarana', category: 'rapid-rail-kl' },
90 | 'kl lrt': { provider: 'prasarana', category: 'rapid-rail-kl' },
91 | 'kl monorail': { provider: 'prasarana', category: 'rapid-rail-kl' },
92 | 'kl monorel': { provider: 'prasarana', category: 'rapid-rail-kl' },
93 | 'rapid kl bus': { provider: 'prasarana', category: 'rapid-bus-kl' },
94 | 'rapid bus kl': { provider: 'prasarana', category: 'rapid-bus-kl' },
95 | 'rapid kl': { provider: 'prasarana', category: 'rapid-rail-kl' }, // Default to rail when just 'rapid kl' is specified
96 | 'rapid penang': { provider: 'prasarana', category: 'rapid-bus-penang' },
97 | 'rapid bus penang': { provider: 'prasarana', category: 'rapid-bus-penang' },
98 | 'rapid kuantan': { provider: 'prasarana', category: 'rapid-bus-kuantan' },
99 | 'rapid bus kuantan': { provider: 'prasarana', category: 'rapid-bus-kuantan' },
100 | 'mrt feeder': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' },
101 | 'rapid feeder': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' },
102 | 'rapid feeder kl': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' },
103 | 'rapid bus mrt feeder': { provider: 'prasarana', category: 'rapid-bus-mrtfeeder' }
104 | };
105 |
106 | /**
107 | * Normalize provider and category from user input
108 | * @param provider Provider name from user input
109 | * @param category Optional category from user input
110 | * @returns Normalized provider and category
111 | */
112 | function normalizeProviderAndCategory(provider: string, category?: string): { provider: string; category?: string; error?: string } {
113 | // Convert to lowercase for case-insensitive matching
114 | const normalizedProvider = provider.toLowerCase();
115 | let normalizedCategory = category;
116 |
117 | // Check if this is a known provider/service in our mappings
118 | if (PROVIDER_MAPPINGS[normalizedProvider]) {
119 | return PROVIDER_MAPPINGS[normalizedProvider];
120 | }
121 |
122 | // If not in mappings, check if it's a valid provider
123 | if (!VALID_PROVIDERS.includes(normalizedProvider)) {
124 | return {
125 | provider,
126 | category,
127 | error: `Invalid provider: ${provider}. Valid providers are: ${VALID_PROVIDERS.join(', ')}`
128 | };
129 | }
130 |
131 | // For prasarana, validate the category
132 | if (normalizedProvider === 'prasarana') {
133 | if (!category) {
134 | return {
135 | provider: normalizedProvider,
136 | error: 'Category parameter is required for prasarana provider'
137 | };
138 | }
139 |
140 | // Normalize category to lowercase for case-insensitive matching
141 | normalizedCategory = category.toLowerCase();
142 |
143 | if (!PRASARANA_CATEGORIES.includes(normalizedCategory)) {
144 | return {
145 | provider: normalizedProvider,
146 | category,
147 | error: `Invalid category for prasarana: ${category}. Valid categories are: ${PRASARANA_CATEGORIES.join(', ')}`
148 | };
149 | }
150 | }
151 |
152 | // Return normalized values
153 | return {
154 | provider: normalizedProvider,
155 | category: normalizedCategory
156 | };
157 | }
158 |
159 | // Export geocoding functions for testing
160 | export { geocodeLocation, geocodeWithGrabMaps, geocodeWithNominatim, haversineDistance };
161 |
162 | // Cache for GTFS data to avoid repeated downloads and parsing
163 | const gtfsCache = {
164 | static: new Map<string, { data: any; timestamp: number }>(),
165 | realtime: new Map<string, { data: any; timestamp: number }>(),
166 | tripUpdates: new Map<string, { data: any; timestamp: number }>(),
167 | };
168 |
169 | // Cache expiry times (in milliseconds)
170 | const STATIC_CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
171 | const REALTIME_CACHE_EXPIRY = 30 * 1000; // 30 seconds
172 | const TRIP_UPDATES_CACHE_EXPIRY = 30 * 1000; // 30 seconds
173 |
174 | /**
175 | * Parse CSV data from a readable stream
176 | * @param stream Readable stream containing CSV data
177 | * @returns Promise resolving to an array of parsed objects
178 | */
179 | async function parseCsv(stream: Readable): Promise<any[]> {
180 | return new Promise((resolve, reject) => {
181 | const results: any[] = [];
182 |
183 | stream
184 | .pipe(csvParser())
185 | .on('data', (data) => results.push(data))
186 | .on('end', () => resolve(results))
187 | .on('error', (error) => reject(error));
188 | });
189 | }
190 |
191 | /**
192 | * Parse GTFS Static data from a ZIP file
193 | * @param buffer Buffer containing the ZIP file
194 | * @returns Promise resolving to parsed GTFS data
195 | */
196 | async function parseGtfsStaticZip(buffer: Buffer): Promise<Record<string, any[]>> {
197 | const zip = new JSZip();
198 | const contents = await zip.loadAsync(buffer);
199 | const result: Record<string, any[]> = {};
200 |
201 | // List of core GTFS files to parse
202 | const coreFiles = [
203 | 'agency.txt',
204 | 'stops.txt',
205 | 'routes.txt',
206 | 'trips.txt',
207 | 'stop_times.txt',
208 | 'calendar.txt',
209 | 'calendar_dates.txt',
210 | 'shapes.txt',
211 | 'frequencies.txt',
212 | ];
213 |
214 | // Parse each file in the ZIP
215 | for (const fileName of Object.keys(contents.files)) {
216 | // Skip directories and non-core files
217 | if (contents.files[fileName].dir || !coreFiles.includes(fileName)) {
218 | continue;
219 | }
220 |
221 | try {
222 | // Get file content as text
223 | const fileData = await contents.files[fileName].async('nodebuffer');
224 | const stream = Readable.from(fileData);
225 |
226 | // Parse CSV data
227 | const parsedData = await parseCsv(stream);
228 |
229 | // Store parsed data
230 | const fileNameWithoutExt = fileName.replace('.txt', '');
231 | result[fileNameWithoutExt] = parsedData;
232 | } catch (error) {
233 | console.error(`Error parsing ${fileName}:`, error);
234 | }
235 | }
236 |
237 | return result;
238 | }
239 |
240 | /**
241 | * Enhance location query with Malaysian context if needed
242 | * @param query Original location query
243 | * @returns Enhanced query with better context for geocoding
244 | */
245 | function enhanceLocationQuery(query: string): string {
246 | // Don't modify if already contains state/country information
247 | const malaysianStates = ['penang', 'pulau pinang', 'selangor', 'kuala lumpur', 'kl', 'johor', 'kedah', 'kelantan',
248 | 'melaka', 'malacca', 'negeri sembilan', 'pahang', 'perak', 'perlis', 'sabah',
249 | 'sarawak', 'terengganu', 'labuan', 'putrajaya'];
250 |
251 | // Check if query already contains state information
252 | const lowercaseQuery = query.toLowerCase();
253 | const hasStateInfo = malaysianStates.some(state => lowercaseQuery.includes(state));
254 |
255 | if (hasStateInfo || lowercaseQuery.includes('malaysia')) {
256 | return query; // Already has sufficient context
257 | }
258 |
259 | // Special handling for specific hotels in Penang
260 | const penangHotels = [
261 | 'hompton hotel', 'cititel', 'g hotel', 'eastern & oriental', 'e&o hotel', 'shangri-la',
262 | 'shangri la', 'holiday inn', 'tune hotel', 'hotel jen', 'the light', 'lexis suites',
263 | 'hard rock hotel', 'bayview', 'equatorial', 'four points', 'vouk hotel', 'neo+', 'neo plus',
264 | 'royale chulan', 'the wembley', 'sunway hotel', 'hotel royal', 'st giles', 'flamingo'
265 | ];
266 |
267 | // Check if query contains any Penang hotel names
268 | if (penangHotels.some(hotel => lowercaseQuery.includes(hotel))) {
269 | return `${query}, Penang, Malaysia`;
270 | }
271 |
272 | // Check for common hotel chains or landmarks that might need context
273 | if (lowercaseQuery.includes('hotel') ||
274 | lowercaseQuery.includes('mall') ||
275 | lowercaseQuery.includes('airport')) {
276 |
277 | // Check for Penang-specific locations
278 | if (lowercaseQuery.includes('bayan lepas') ||
279 | lowercaseQuery.includes('georgetown') ||
280 | lowercaseQuery.includes('george town') ||
281 | lowercaseQuery.includes('butterworth') ||
282 | lowercaseQuery.includes('bukit mertajam') ||
283 | lowercaseQuery.includes('batu ferringhi')) {
284 | return `${query}, Penang, Malaysia`;
285 | }
286 |
287 | // Add Malaysia as context to improve geocoding results
288 | return `${query}, Malaysia`;
289 | }
290 |
291 | return query;
292 | }
293 |
294 | // Get GrabMaps API key from environment variable
295 | let grabMapsApiKeyLastChecked = 0;
296 | let cachedGrabMapsApiKey = '';
297 |
298 | function getGrabMapsApiKey(): string {
299 | // Only check once per minute to avoid excessive environment variable lookups
300 | const now = Date.now();
301 | if (now - grabMapsApiKeyLastChecked > 60000) {
302 | cachedGrabMapsApiKey = process.env.GRABMAPS_API_KEY || '';
303 | grabMapsApiKeyLastChecked = now;
304 |
305 | if (!cachedGrabMapsApiKey) {
306 | console.log('No GrabMaps API key found.');
307 | } else {
308 | console.log('GrabMaps API key available.');
309 | }
310 | }
311 |
312 | return cachedGrabMapsApiKey;
313 | }
314 |
315 | /**
316 | * Geocode a location name to coordinates using available providers with fallback
317 | * @param query Location name to geocode
318 | * @param country Optional country code to limit results (e.g., 'my' for Malaysia)
319 | * @returns Promise with coordinates or null if not found
320 | */
321 | async function geocodeLocation(query: string, country: string = 'my'): Promise<{ lat: number; lon: number } | null> {
322 | try {
323 | // Enhance the query with better context
324 | const enhancedQuery = enhanceLocationQuery(query);
325 |
326 | // Get API keys for different providers
327 | const googleMapsApiKey = getGoogleMapsApiKey();
328 | const grabMapsApiKey = getGrabMapsApiKey();
329 |
330 | // Try GrabMaps first for Southeast Asian countries (preferred for the region)
331 | const seaCountries = ['my', 'sg', 'id', 'th', 'ph', 'vn', 'mm', 'la', 'kh', 'bn', 'tl'];
332 | if (grabMapsApiKey && seaCountries.includes(country.toLowerCase())) {
333 | console.log('Attempting to geocode with GrabMaps (preferred for Southeast Asia)');
334 | const grabMapsResult = await geocodeWithGrabMaps(enhancedQuery, query, country, grabMapsApiKey);
335 | if (grabMapsResult) {
336 | return grabMapsResult;
337 | }
338 | console.log('GrabMaps geocoding failed, falling back to other providers');
339 | }
340 |
341 | // Try Google Maps if API key is available
342 | if (googleMapsApiKey) {
343 | console.log('Attempting to geocode with Google Maps');
344 | const googleResult = await geocodeWithGoogleMaps(enhancedQuery, query, country, googleMapsApiKey);
345 | if (googleResult) {
346 | return googleResult;
347 | }
348 | console.log('Google Maps geocoding failed, falling back to Nominatim');
349 | }
350 |
351 | // Fall back to Nominatim (always available as open source solution)
352 | console.log('Attempting to geocode with Nominatim');
353 | return await geocodeWithNominatim(enhancedQuery, query, country);
354 | } catch (error) {
355 | console.error('Geocoding error:', error);
356 | return null;
357 | }
358 | }
359 |
360 | /**
361 | * Geocode using Google Maps API
362 | */
363 | async function geocodeWithGoogleMaps(enhancedQuery: string, originalQuery: string, country: string, apiKey: string): Promise<{ lat: number; lon: number } | null> {
364 | // Build URL with parameters for Google Maps API
365 | const params = new URLSearchParams({
366 | address: enhancedQuery,
367 | components: `country:${country}`,
368 | key: apiKey
369 | });
370 |
371 | // Make request to Google Maps Geocoding API
372 | console.log(`Geocoding with Google Maps API: "${enhancedQuery}"`);
373 | const response = await axios.get(`${GOOGLE_MAPS_GEOCODING_API}?${params.toString()}`);
374 |
375 | // Check if we got any results
376 | if (response.data &&
377 | response.data.status === 'OK' &&
378 | response.data.results &&
379 | response.data.results.length > 0) {
380 |
381 | const result = response.data.results[0];
382 | const location = result.geometry.location;
383 |
384 | console.log(`Google Maps found location: ${result.formatted_address}`);
385 |
386 | return {
387 | lat: location.lat,
388 | lon: location.lng
389 | };
390 | } else {
391 | console.log(`Google Maps API returned status: ${response.data.status}`);
392 | }
393 |
394 | // If enhanced query failed and it was different from original, try the original
395 | if (enhancedQuery !== originalQuery) {
396 | console.log(`Enhanced query failed, trying original query: ${originalQuery}`);
397 |
398 | const originalParams = new URLSearchParams({
399 | address: originalQuery,
400 | components: `country:${country}`,
401 | key: apiKey
402 | });
403 |
404 | const originalResponse = await axios.get(`${GOOGLE_MAPS_GEOCODING_API}?${originalParams.toString()}`);
405 |
406 | if (originalResponse.data &&
407 | originalResponse.data.status === 'OK' &&
408 | originalResponse.data.results &&
409 | originalResponse.data.results.length > 0) {
410 |
411 | const result = originalResponse.data.results[0];
412 | const location = result.geometry.location;
413 |
414 | console.log(`Google Maps found location with original query: ${result.formatted_address}`);
415 |
416 | return {
417 | lat: location.lat,
418 | lon: location.lng
419 | };
420 | } else {
421 | console.log(`Google Maps API returned status for original query: ${originalResponse.data.status}`);
422 | }
423 | }
424 |
425 | return null;
426 | }
427 |
428 | /**
429 | * Geocode using GrabMaps API via AWS Location Service
430 | *
431 | * Note: This requires valid AWS credentials with permissions to access AWS Location Service.
432 | * If the credentials are invalid or missing, the function will return null and log an error.
433 | *
434 | * Prerequisites for using this function:
435 | * 1. Valid AWS Access Key ID and Secret Access Key with Location Service permissions
436 | * 2. A Place Index created in AWS Location Service with GrabMaps as the data provider
437 | * 3. GrabMaps API key
438 | * 4. Correct AWS region configuration (ap-southeast-5 for Malaysia)
439 | *
440 | * @param enhancedQuery Enhanced query with additional context
441 | * @param originalQuery Original query without enhancement
442 | * @param country Country code (e.g., 'my' for Malaysia)
443 | * @param apiKey GrabMaps API key
444 | * @returns Coordinates or null if geocoding failed
445 | */
446 | async function geocodeWithGrabMaps(enhancedQuery: string, originalQuery: string, country: string, apiKey: string): Promise<{ lat: number; lon: number } | null> {
447 | console.log(`Attempting to geocode with GrabMaps via AWS Location Service: "${enhancedQuery}"`);
448 |
449 | try {
450 | // Check for required AWS credentials
451 | const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
452 | const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
453 | const awsRegion = process.env.AWS_REGION || 'ap-southeast-5';
454 | const grabMapsApiKey = process.env.GRABMAPS_API_KEY || apiKey;
455 |
456 | if (!accessKeyId) {
457 | console.error('AWS Access Key ID not found in environment variables');
458 | return null;
459 | }
460 |
461 | if (!secretAccessKey) {
462 | console.error('AWS Secret Access Key not found in environment variables');
463 | return null;
464 | }
465 |
466 | if (!grabMapsApiKey) {
467 | console.error('GrabMaps API key not found in environment variables');
468 | return null;
469 | }
470 |
471 | if (!awsRegion) {
472 | console.error('AWS Region not found in environment variables, using default ap-southeast-5');
473 | // We don't return null here as we have a default value
474 | }
475 |
476 | // Create a new AWS Location Service client
477 | const client = new LocationClient({
478 | region: awsRegion, // Use region from env vars or default to Singapore
479 | credentials: {
480 | accessKeyId,
481 | secretAccessKey
482 | }
483 | });
484 |
485 | console.log(`Using AWS region: ${awsRegion}`);
486 |
487 | console.log('AWS Location Service client created. Attempting to geocode...');
488 |
489 | // Convert 2-letter country code to 3-letter code for AWS Location Service
490 | // AWS Location Service requires 3-letter ISO country codes
491 | const countryCode2 = country.toLowerCase();
492 | let countryCode3 = 'MYS'; // Default to Malaysia
493 |
494 | // Map of 2-letter to 3-letter country codes for Southeast Asia
495 | const countryCodes: Record<string, string> = {
496 | 'my': 'MYS', // Malaysia
497 | 'sg': 'SGP', // Singapore
498 | 'id': 'IDN', // Indonesia
499 | 'th': 'THA', // Thailand
500 | 'ph': 'PHL', // Philippines
501 | 'vn': 'VNM', // Vietnam
502 | 'mm': 'MMR', // Myanmar
503 | 'la': 'LAO', // Laos
504 | 'kh': 'KHM', // Cambodia
505 | 'bn': 'BRN', // Brunei
506 | 'tl': 'TLS' // Timor-Leste
507 | };
508 |
509 | if (countryCode2 in countryCodes) {
510 | countryCode3 = countryCodes[countryCode2 as keyof typeof countryCodes];
511 | }
512 |
513 | console.log(`Using 3-letter country code: ${countryCode3}`);
514 |
515 | // Create the search command
516 | const command = new SearchPlaceIndexForTextCommand({
517 | IndexName: 'explore.place.Grab', // The name of your Place Index with GrabMaps data provider
518 | Text: enhancedQuery,
519 | BiasPosition: [101.6942371, 3.1516964], // Bias towards KL, Malaysia
520 | FilterCountries: [countryCode3], // Filter by country
521 | MaxResults: 1
522 | });
523 |
524 | // Send the command
525 | const response = await client.send(command);
526 |
527 | // Process the response
528 | if (response.Results && response.Results.length > 0 && response.Results[0].Place?.Geometry?.Point) {
529 | const point = response.Results[0].Place.Geometry.Point;
530 | const result = {
531 | lat: point[1], // AWS returns [longitude, latitude]
532 | lon: point[0]
533 | };
534 |
535 | console.log(`\u2705 GrabMaps geocoding successful: ${JSON.stringify(result)}`);
536 | console.log(`Location: ${response.Results[0].Place.Label}`);
537 |
538 | return result;
539 | }
540 |
541 | console.log('No results found with GrabMaps via AWS Location Service');
542 | return null;
543 | } catch (error) {
544 | console.error('Error geocoding with GrabMaps via AWS Location Service:', error);
545 |
546 | // Check for specific AWS errors
547 | if (error && typeof error === 'object' && 'name' in error) {
548 | const awsError = error as { name: string };
549 |
550 | if (awsError.name === 'UnrecognizedClientException') {
551 | console.error('AWS authentication failed. Please check your AWS credentials.');
552 | } else if (awsError.name === 'ValidationException') {
553 | console.error('AWS Location Service validation error. Please check your request parameters.');
554 | } else if (awsError.name === 'ResourceNotFoundException') {
555 | console.error('Place Index not found. Please check if "explore.place.Grab" exists in your AWS account.');
556 | }
557 | }
558 |
559 | return null;
560 | }
561 | }
562 |
563 | /**
564 | * Geocode using Nominatim API (OpenStreetMap)
565 | */
566 | async function geocodeWithNominatim(enhancedQuery: string, originalQuery: string, country: string): Promise<{ lat: number; lon: number } | null> {
567 | // Build URL with parameters for Nominatim
568 | const params = new URLSearchParams({
569 | q: enhancedQuery,
570 | format: 'json',
571 | limit: '1',
572 | countrycodes: country,
573 | });
574 |
575 | // Make request to Nominatim API
576 | console.log(`Geocoding with Nominatim API: "${enhancedQuery}"`);
577 | const response = await axios.get(`${NOMINATIM_API}?${params.toString()}`, {
578 | headers: {
579 | 'User-Agent': 'Malaysia-Open-Data-MCP-Server/1.0',
580 | },
581 | });
582 |
583 | // Check if we got any results
584 | if (response.data && response.data.length > 0) {
585 | const result = response.data[0];
586 | console.log(`Nominatim found location: ${result.display_name}`);
587 | return {
588 | lat: parseFloat(result.lat),
589 | lon: parseFloat(result.lon),
590 | };
591 | }
592 |
593 | // If enhanced query failed and it was different from original, try the original
594 | if (enhancedQuery !== originalQuery) {
595 | console.log(`Enhanced query failed, trying original query with Nominatim: ${originalQuery}`);
596 | const originalParams = new URLSearchParams({
597 | q: originalQuery,
598 | format: 'json',
599 | limit: '1',
600 | countrycodes: country,
601 | });
602 |
603 | const originalResponse = await axios.get(`${NOMINATIM_API}?${originalParams.toString()}`, {
604 | headers: {
605 | 'User-Agent': 'Malaysia-Open-Data-MCP-Server/1.0',
606 | },
607 | });
608 |
609 | if (originalResponse.data && originalResponse.data.length > 0) {
610 | const result = originalResponse.data[0];
611 | console.log(`Nominatim found location with original query: ${result.display_name}`);
612 | return {
613 | lat: parseFloat(result.lat),
614 | lon: parseFloat(result.lon),
615 | };
616 | }
617 | }
618 |
619 | return null;
620 | }
621 |
622 | /**
623 | * Calculate the Haversine distance between two points in kilometers
624 | * @param lat1 Latitude of point 1
625 | * @param lon1 Longitude of point 1
626 | * @param lat2 Latitude of point 2
627 | * @param lon2 Longitude of point 2
628 | * @returns Distance in kilometers
629 | */
630 | function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
631 | // Convert latitude and longitude from degrees to radians
632 | const toRadians = (degrees: number) => degrees * Math.PI / 180;
633 |
634 | const dLat = toRadians(lat2 - lat1);
635 | const dLon = toRadians(lon2 - lon1);
636 | lat1 = toRadians(lat1);
637 | lat2 = toRadians(lat2);
638 |
639 | // Haversine formula
640 | const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
641 | Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
642 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
643 |
644 | // Earth's radius in kilometers
645 | const R = 6371;
646 |
647 | // Return distance in kilometers
648 | return R * c;
649 | }
650 |
651 | /**
652 | * Register GTFS tools with the MCP server
653 | * @param server MCP server instance
654 | */
655 | export function registerGtfsTools(server: McpServer) {
656 | // Parse GTFS Static data
657 | server.tool(
658 | prefixToolName('parse_gtfs_static'),
659 | '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.',
660 | {
661 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'),
662 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
663 | force_refresh: z.boolean().optional().describe('Force refresh the cache'),
664 | },
665 | async ({ provider, category, force_refresh = false }) => {
666 | try {
667 | // Normalize provider and category
668 | const normalized = normalizeProviderAndCategory(provider, category);
669 |
670 | // If there's an error, return it
671 | if (normalized.error) {
672 | return {
673 | content: [
674 | {
675 | type: 'text',
676 | text: JSON.stringify({
677 | success: false,
678 | message: normalized.error,
679 | valid_providers: VALID_PROVIDERS,
680 | valid_categories: PRASARANA_CATEGORIES,
681 | common_names: Object.keys(PROVIDER_MAPPINGS),
682 | example: normalized.provider === 'prasarana' ? {
683 | provider: 'prasarana',
684 | category: 'rapid-rail-kl'
685 | } : undefined
686 | }, null, 2),
687 | },
688 | ],
689 | };
690 | }
691 |
692 | // Use normalized values
693 | const normalizedProvider = normalized.provider;
694 | const normalizedCategory = normalized.category;
695 |
696 | // Build cache key
697 | const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`;
698 |
699 | // Check cache if not forcing refresh
700 | if (!force_refresh && gtfsCache.static.has(cacheKey)) {
701 | const cached = gtfsCache.static.get(cacheKey)!;
702 |
703 | // Return cached data if not expired
704 | if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) {
705 | return {
706 | content: [
707 | {
708 | type: 'text',
709 | text: JSON.stringify({
710 | success: true,
711 | message: 'Successfully retrieved GTFS static data from cache',
712 | data: cached.data,
713 | cached: true,
714 | timestamp: cached.timestamp,
715 | }, null, 2),
716 | },
717 | ],
718 | };
719 | }
720 | }
721 |
722 | // Build URL
723 | let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}`;
724 |
725 | if (category) {
726 | url += `?category=${category}`;
727 | }
728 |
729 | // Download ZIP file
730 | const response = await axios.get(url, { responseType: 'arraybuffer' });
731 |
732 | // Parse GTFS data
733 | const gtfsData = await parseGtfsStaticZip(Buffer.from(response.data));
734 |
735 | // Cache the result
736 | gtfsCache.static.set(cacheKey, {
737 | data: gtfsData,
738 | timestamp: Date.now(),
739 | });
740 |
741 | return {
742 | content: [
743 | {
744 | type: 'text',
745 | text: JSON.stringify({
746 | success: true,
747 | message: `Successfully parsed GTFS static data for provider: ${provider}${category ? `, category: ${category}` : ''}`,
748 | data: gtfsData,
749 | cached: false,
750 | }, null, 2),
751 | },
752 | ],
753 | };
754 | } catch (error) {
755 | // Check if it's an axios error with response data
756 | const axiosError = error as any;
757 | const statusCode = axiosError?.response?.status;
758 | const responseData = axiosError?.response?.data;
759 |
760 | return {
761 | content: [
762 | {
763 | type: 'text',
764 | text: JSON.stringify({
765 | success: false,
766 | message: 'Failed to parse GTFS static data',
767 | error: error instanceof Error ? error.message : 'Unknown error',
768 | status_code: statusCode,
769 | api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`,
770 | response_data: responseData,
771 | provider_info: {
772 | provider: provider,
773 | category: category,
774 | valid_providers: VALID_PROVIDERS,
775 | valid_categories: PRASARANA_CATEGORIES
776 | },
777 | note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required."
778 | }, null, 2),
779 | },
780 | ],
781 | };
782 | }
783 | }
784 | );
785 |
786 | // Parse GTFS Realtime data
787 | server.tool(
788 | prefixToolName('parse_gtfs_realtime'),
789 | '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.',
790 | {
791 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'),
792 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
793 | force_refresh: z.boolean().optional().describe('Force refresh the cache'),
794 | },
795 | async ({ provider, category, force_refresh = false }) => {
796 | try {
797 | // Normalize provider and category
798 | const normalized = normalizeProviderAndCategory(provider, category);
799 |
800 | // If there's an error, return it
801 | if (normalized.error) {
802 | return {
803 | content: [
804 | {
805 | type: 'text',
806 | text: JSON.stringify({
807 | success: false,
808 | message: normalized.error,
809 | valid_providers: VALID_PROVIDERS,
810 | valid_categories: PRASARANA_CATEGORIES,
811 | common_names: Object.keys(PROVIDER_MAPPINGS),
812 | example: normalized.provider === 'prasarana' ? {
813 | provider: 'prasarana',
814 | category: 'rapid-rail-kl'
815 | } : undefined
816 | }, null, 2),
817 | },
818 | ],
819 | };
820 | }
821 |
822 | // Use normalized values
823 | const normalizedProvider = normalized.provider;
824 | const normalizedCategory = normalized.category;
825 |
826 | // Build cache key
827 | const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`;
828 |
829 | // Check cache if not forcing refresh
830 | if (!force_refresh && gtfsCache.realtime.has(cacheKey)) {
831 | const cached = gtfsCache.realtime.get(cacheKey)!;
832 |
833 | // Return cached data if not expired
834 | if (Date.now() - cached.timestamp < REALTIME_CACHE_EXPIRY) {
835 | return {
836 | content: [
837 | {
838 | type: 'text',
839 | text: JSON.stringify({
840 | success: true,
841 | message: 'Successfully retrieved GTFS realtime data from cache',
842 | data: cached.data,
843 | cached: true,
844 | timestamp: cached.timestamp,
845 | }, null, 2),
846 | },
847 | ],
848 | };
849 | }
850 | }
851 |
852 | // Build URL
853 | let url = `${API_BASE_URL}${GTFS_REALTIME_ENDPOINT}/${provider}/`;
854 |
855 | if (category) {
856 | url += `?category=${category}`;
857 | }
858 |
859 | url += '/';
860 |
861 | // Download Protocol Buffer data
862 | const response = await axios.get(url, { responseType: 'arraybuffer' });
863 |
864 | // Parse Protocol Buffer data
865 | const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
866 | new Uint8Array(response.data)
867 | );
868 |
869 | // Convert to plain JavaScript object
870 | const vehiclePositions = feed.entity.map(entity => {
871 | if (!entity.vehicle) {
872 | return null;
873 | }
874 |
875 | const vehicle = entity.vehicle;
876 | return {
877 | id: entity.id,
878 | vehicle: {
879 | trip: vehicle.trip ? {
880 | tripId: vehicle.trip.tripId,
881 | routeId: vehicle.trip.routeId,
882 | directionId: vehicle.trip.directionId,
883 | startTime: vehicle.trip.startTime,
884 | startDate: vehicle.trip.startDate,
885 | scheduleRelationship: vehicle.trip.scheduleRelationship,
886 | } : undefined,
887 | position: vehicle.position ? {
888 | latitude: vehicle.position.latitude,
889 | longitude: vehicle.position.longitude,
890 | bearing: vehicle.position.bearing,
891 | speed: vehicle.position.speed,
892 | } : undefined,
893 | currentStopSequence: vehicle.currentStopSequence,
894 | stopId: vehicle.stopId,
895 | currentStatus: vehicle.currentStatus,
896 | timestamp: vehicle.timestamp ? new Date(typeof vehicle.timestamp === 'number' ? vehicle.timestamp * 1000 : (vehicle.timestamp as any).low * 1000).toISOString() : undefined,
897 | congestionLevel: vehicle.congestionLevel,
898 | occupancyStatus: vehicle.occupancyStatus,
899 | },
900 | };
901 | }).filter(Boolean);
902 |
903 | // Cache the result
904 | gtfsCache.realtime.set(cacheKey, {
905 | data: vehiclePositions,
906 | timestamp: Date.now(),
907 | });
908 |
909 | return {
910 | content: [
911 | {
912 | type: 'text',
913 | text: JSON.stringify({
914 | success: true,
915 | message: `Successfully parsed GTFS realtime data for provider: ${provider}${category ? `, category: ${category}` : ''}`,
916 | data: vehiclePositions,
917 | cached: false,
918 | count: vehiclePositions.length,
919 | }, null, 2),
920 | },
921 | ],
922 | };
923 | } catch (error) {
924 | // Check if it's an axios error with response data
925 | const axiosError = error as any;
926 | const statusCode = axiosError?.response?.status;
927 | const responseData = axiosError?.response?.data;
928 |
929 | return {
930 | content: [
931 | {
932 | type: 'text',
933 | text: JSON.stringify({
934 | success: false,
935 | message: 'Failed to parse GTFS realtime data',
936 | error: error instanceof Error ? error.message : 'Unknown error',
937 | status_code: statusCode,
938 | api_url: `${API_BASE_URL}${GTFS_REALTIME_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`,
939 | response_data: responseData,
940 | provider_info: {
941 | provider: provider,
942 | category: category,
943 | valid_providers: VALID_PROVIDERS,
944 | valid_categories: PRASARANA_CATEGORIES
945 | },
946 | note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required."
947 | }, null, 2),
948 | },
949 | ],
950 | };
951 | }
952 | }
953 | );
954 |
955 | // Get transit routes
956 | server.tool(
957 | prefixToolName('get_transit_routes'),
958 | '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.',
959 | {
960 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'),
961 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
962 | route_id: z.string().optional().describe('Specific route ID to filter by'),
963 | },
964 | async ({ provider, category, route_id }) => {
965 | try {
966 | // Normalize provider and category
967 | const normalized = normalizeProviderAndCategory(provider, category);
968 |
969 | // If there's an error, return it
970 | if (normalized.error) {
971 | return {
972 | content: [
973 | {
974 | type: 'text',
975 | text: JSON.stringify({
976 | success: false,
977 | message: normalized.error,
978 | valid_providers: VALID_PROVIDERS,
979 | valid_categories: PRASARANA_CATEGORIES,
980 | common_names: Object.keys(PROVIDER_MAPPINGS),
981 | example: normalized.provider === 'prasarana' ? {
982 | provider: 'prasarana',
983 | category: 'rapid-rail-kl'
984 | } : undefined
985 | }, null, 2),
986 | },
987 | ],
988 | };
989 | }
990 |
991 | // Use normalized values
992 | const normalizedProvider = normalized.provider;
993 | const normalizedCategory = normalized.category;
994 |
995 | // Build cache key
996 | const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`;
997 |
998 | // Check if we have cached GTFS data
999 | let gtfsData;
1000 | if (gtfsCache.static.has(cacheKey)) {
1001 | const cached = gtfsCache.static.get(cacheKey)!;
1002 |
1003 | // Use cached data if not expired
1004 | if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) {
1005 | gtfsData = cached.data;
1006 | }
1007 | }
1008 |
1009 | // If no cached data, fetch and parse GTFS data
1010 | if (!gtfsData) {
1011 | // Build URL
1012 | let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`;
1013 |
1014 | if (normalizedCategory) {
1015 | url += `?category=${normalizedCategory}`;
1016 | }
1017 |
1018 | // Trailing slash already added
1019 |
1020 | // Download ZIP file
1021 | const response = await axios.get(url, { responseType: 'arraybuffer' });
1022 |
1023 | // Parse GTFS data
1024 | gtfsData = await parseGtfsStaticZip(Buffer.from(response.data));
1025 |
1026 | // Cache the result
1027 | gtfsCache.static.set(cacheKey, {
1028 | data: gtfsData,
1029 | timestamp: Date.now(),
1030 | });
1031 | }
1032 |
1033 | // Extract routes data
1034 | const routes = gtfsData.routes || [];
1035 |
1036 | // Filter by route_id if provided
1037 | const filteredRoutes = route_id
1038 | ? routes.filter((route: { route_id: string }) => route.route_id === route_id)
1039 | : routes;
1040 |
1041 | // Add trips information to each route
1042 | const routesWithTrips = filteredRoutes.map((route: { route_id: string }) => {
1043 | const trips = (gtfsData.trips || [])
1044 | .filter((trip: { route_id: string }) => trip.route_id === route.route_id);
1045 |
1046 | return {
1047 | ...route,
1048 | trips_count: trips.length,
1049 | };
1050 | });
1051 |
1052 | return {
1053 | content: [
1054 | {
1055 | type: 'text',
1056 | text: JSON.stringify({
1057 | success: true,
1058 | message: `Successfully retrieved routes for provider: ${provider}${category ? `, category: ${category}` : ''}`,
1059 | data: routesWithTrips,
1060 | count: routesWithTrips.length,
1061 | }, null, 2),
1062 | },
1063 | ],
1064 | };
1065 | } catch (error) {
1066 | // Check if it's an axios error with response data
1067 | const axiosError = error as any;
1068 | const statusCode = axiosError?.response?.status;
1069 | const responseData = axiosError?.response?.data;
1070 |
1071 | return {
1072 | content: [
1073 | {
1074 | type: 'text',
1075 | text: JSON.stringify({
1076 | success: false,
1077 | message: 'Failed to retrieve transit routes',
1078 | error: error instanceof Error ? error.message : 'Unknown error',
1079 | status_code: statusCode,
1080 | api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`,
1081 | response_data: responseData,
1082 | provider_info: {
1083 | provider: provider,
1084 | category: category,
1085 | valid_providers: VALID_PROVIDERS,
1086 | valid_categories: PRASARANA_CATEGORIES
1087 | },
1088 | note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required."
1089 | }, null, 2),
1090 | },
1091 | ],
1092 | };
1093 | }
1094 | }
1095 | );
1096 |
1097 | // Get transit stops
1098 | server.tool(
1099 | prefixToolName('get_transit_stops'),
1100 | '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.',
1101 | {
1102 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana")'),
1103 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
1104 | route_id: z.string().optional().describe('Filter stops by route ID (optional)'),
1105 | stop_id: z.string().optional().describe('Specific stop ID to retrieve (optional)'),
1106 | },
1107 | async ({ provider, category, route_id, stop_id }) => {
1108 | try {
1109 | // Normalize provider and category
1110 | const normalized = normalizeProviderAndCategory(provider, category);
1111 |
1112 | // If there's an error, return it
1113 | if (normalized.error) {
1114 | return {
1115 | content: [
1116 | {
1117 | type: 'text',
1118 | text: JSON.stringify({
1119 | success: false,
1120 | message: normalized.error,
1121 | valid_providers: VALID_PROVIDERS,
1122 | valid_categories: PRASARANA_CATEGORIES,
1123 | common_names: Object.keys(PROVIDER_MAPPINGS),
1124 | example: normalized.provider === 'prasarana' ? {
1125 | provider: 'prasarana',
1126 | category: 'rapid-rail-kl'
1127 | } : undefined
1128 | }, null, 2),
1129 | },
1130 | ],
1131 | };
1132 | }
1133 |
1134 | // Use normalized values
1135 | const normalizedProvider = normalized.provider;
1136 | const normalizedCategory = normalized.category;
1137 |
1138 | // Build cache key
1139 | const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`;
1140 |
1141 | // Check if we have cached GTFS data
1142 | let gtfsData;
1143 | if (gtfsCache.static.has(cacheKey)) {
1144 | const cached = gtfsCache.static.get(cacheKey)!;
1145 |
1146 | // Use cached data if not expired
1147 | if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) {
1148 | gtfsData = cached.data;
1149 | }
1150 | }
1151 |
1152 | // If no cached data, fetch and parse GTFS data
1153 | if (!gtfsData) {
1154 | // Build URL
1155 | let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`;
1156 |
1157 | if (normalizedCategory) {
1158 | url += `?category=${normalizedCategory}`;
1159 | }
1160 |
1161 | // Trailing slash already added
1162 |
1163 | // Download ZIP file
1164 | const response = await axios.get(url, { responseType: 'arraybuffer' });
1165 |
1166 | // Parse GTFS data
1167 | gtfsData = await parseGtfsStaticZip(Buffer.from(response.data));
1168 |
1169 | // Cache the result
1170 | gtfsCache.static.set(cacheKey, {
1171 | data: gtfsData,
1172 | timestamp: Date.now(),
1173 | });
1174 | }
1175 |
1176 | // Extract stops data
1177 | const stops = gtfsData.stops || [];
1178 |
1179 | // Filter by stop_id if provided
1180 | let filteredStops = stop_id
1181 | ? stops.filter((stop: { stop_id: string }) => stop.stop_id === stop_id)
1182 | : stops;
1183 |
1184 | // If route_id is provided, filter stops by route
1185 | if (route_id) {
1186 | // Get trips for the route
1187 | const routeTrips = (gtfsData.trips || [])
1188 | .filter((trip: { route_id: string; trip_id: string }) => trip.route_id === route_id)
1189 | .map((trip: { trip_id: string }) => trip.trip_id);
1190 |
1191 | // Get stop_times for the trips
1192 | const stopTimes = (gtfsData.stop_times || [])
1193 | .filter((stopTime: { trip_id: string }) => routeTrips.includes(stopTime.trip_id));
1194 |
1195 | // Get stop_ids from stop_times
1196 | const stopIds = [...new Set(stopTimes.map((stopTime: { stop_id: string }) => stopTime.stop_id))];
1197 |
1198 | // Filter stops by stop_ids
1199 | filteredStops = filteredStops.filter((stop: { stop_id: string }) => stopIds.includes(stop.stop_id));
1200 | }
1201 |
1202 | return {
1203 | content: [
1204 | {
1205 | type: 'text',
1206 | text: JSON.stringify({
1207 | success: true,
1208 | message: `Successfully retrieved stops for provider: ${provider}${category ? `, category: ${category}` : ''}`,
1209 | data: filteredStops,
1210 | count: filteredStops.length,
1211 | }, null, 2),
1212 | },
1213 | ],
1214 | };
1215 | } catch (error) {
1216 | // Check if it's an axios error with response data
1217 | const axiosError = error as any;
1218 | const statusCode = axiosError?.response?.status;
1219 | const responseData = axiosError?.response?.data;
1220 |
1221 | return {
1222 | content: [
1223 | {
1224 | type: 'text',
1225 | text: JSON.stringify({
1226 | success: false,
1227 | message: 'Failed to retrieve transit stops',
1228 | error: error instanceof Error ? error.message : 'Unknown error',
1229 | status_code: statusCode,
1230 | api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`,
1231 | response_data: responseData,
1232 | provider_info: {
1233 | provider: provider,
1234 | category: category,
1235 | valid_providers: VALID_PROVIDERS,
1236 | valid_categories: PRASARANA_CATEGORIES
1237 | },
1238 | note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required."
1239 | }, null, 2),
1240 | },
1241 | ],
1242 | };
1243 | }
1244 | }
1245 | );
1246 |
1247 | // Get transit arrivals
1248 | server.tool(
1249 | prefixToolName('get_transit_arrivals'),
1250 | '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".',
1251 | {
1252 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana", or common names like "rapid penang")'),
1253 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
1254 | stop_id: z.string().describe('ID of the stop to get arrivals for'),
1255 | route_id: z.string().optional().describe('Optional: filter arrivals by route'),
1256 | limit: z.number().optional().describe('Maximum number of arrivals to return (default: 10)'),
1257 | },
1258 | async ({ provider, category, stop_id, route_id, limit = 10 }) => {
1259 | try {
1260 | // Normalize provider and category
1261 | const normalized = normalizeProviderAndCategory(provider, category);
1262 |
1263 | // If there's an error, return it
1264 | if (normalized.error) {
1265 | return {
1266 | content: [
1267 | {
1268 | type: 'text',
1269 | text: JSON.stringify({
1270 | success: false,
1271 | message: normalized.error,
1272 | valid_providers: VALID_PROVIDERS,
1273 | valid_categories: PRASARANA_CATEGORIES,
1274 | common_names: Object.keys(PROVIDER_MAPPINGS),
1275 | example: normalized.provider === 'prasarana' ? {
1276 | provider: 'prasarana',
1277 | category: 'rapid-rail-kl'
1278 | } : undefined
1279 | }, null, 2),
1280 | },
1281 | ],
1282 | };
1283 | }
1284 |
1285 | // Use normalized values
1286 | const normalizedProvider = normalized.provider;
1287 | const normalizedCategory = normalized.category;
1288 |
1289 | // Build cache key
1290 | const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`;
1291 |
1292 | // Get static GTFS data (for stop and route information)
1293 | let gtfsStaticData;
1294 | if (gtfsCache.static.has(cacheKey)) {
1295 | const cached = gtfsCache.static.get(cacheKey)!;
1296 |
1297 | // Use cached data if not expired
1298 | if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) {
1299 | gtfsStaticData = cached.data;
1300 | }
1301 | }
1302 |
1303 | // If no cached static data, fetch and parse GTFS static data
1304 | if (!gtfsStaticData) {
1305 | // Build URL
1306 | let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`;
1307 |
1308 | if (normalizedCategory) {
1309 | url += `?category=${normalizedCategory}`;
1310 | }
1311 |
1312 | // Trailing slash already added
1313 |
1314 | // Download ZIP file
1315 | const response = await axios.get(url, { responseType: 'arraybuffer' });
1316 |
1317 | // Parse GTFS data
1318 | gtfsStaticData = await parseGtfsStaticZip(Buffer.from(response.data));
1319 |
1320 | // Cache the result
1321 | gtfsCache.static.set(cacheKey, {
1322 | data: gtfsStaticData,
1323 | timestamp: Date.now(),
1324 | });
1325 | }
1326 |
1327 | // Get trip updates data (for real-time arrivals)
1328 | let tripUpdatesData: any[] = [];
1329 | if (gtfsCache.tripUpdates.has(cacheKey)) {
1330 | const cached = gtfsCache.tripUpdates.get(cacheKey)!;
1331 |
1332 | // Use cached data if not expired
1333 | if (Date.now() - cached.timestamp < TRIP_UPDATES_CACHE_EXPIRY) {
1334 | tripUpdatesData = cached.data;
1335 | }
1336 |
1337 | }
1338 |
1339 | // If no cached trip updates data, fetch and parse GTFS trip updates
1340 | if (!tripUpdatesData) {
1341 | // Build URL
1342 | let url = `${API_BASE_URL}${GTFS_TRIP_UPDATES_ENDPOINT}/${provider}/`;
1343 |
1344 | if (category) {
1345 | url += `?category=${category}`;
1346 | }
1347 |
1348 | // Trailing slash already added
1349 |
1350 | try {
1351 | // Download Protocol Buffer data
1352 | const response = await axios.get(url, { responseType: 'arraybuffer' });
1353 |
1354 | // Parse Protocol Buffer data
1355 | const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
1356 | new Uint8Array(response.data)
1357 | );
1358 |
1359 | // Convert to plain JavaScript object
1360 | tripUpdatesData = feed.entity.map(entity => {
1361 | if (!entity.tripUpdate) {
1362 | return null;
1363 | }
1364 |
1365 | const tripUpdate = entity.tripUpdate;
1366 | return {
1367 | id: entity.id,
1368 | tripUpdate: {
1369 | trip: tripUpdate.trip ? {
1370 | tripId: tripUpdate.trip.tripId,
1371 | routeId: tripUpdate.trip.routeId,
1372 | directionId: tripUpdate.trip.directionId,
1373 | startTime: tripUpdate.trip.startTime,
1374 | startDate: tripUpdate.trip.startDate,
1375 | scheduleRelationship: tripUpdate.trip.scheduleRelationship,
1376 | } : undefined,
1377 | stopTimeUpdate: tripUpdate.stopTimeUpdate ? tripUpdate.stopTimeUpdate.map(update => ({
1378 | stopSequence: update.stopSequence,
1379 | stopId: update.stopId,
1380 | arrival: update.arrival ? {
1381 | delay: update.arrival.delay,
1382 | time: update.arrival.time ? new Date(typeof update.arrival.time === 'number' ? update.arrival.time * 1000 : (update.arrival.time as any).low * 1000).toISOString() : undefined,
1383 | uncertainty: update.arrival.uncertainty,
1384 | } : undefined,
1385 | departure: update.departure ? {
1386 | delay: update.departure.delay,
1387 | time: update.departure.time ? new Date(typeof update.departure.time === 'number' ? update.departure.time * 1000 : (update.departure.time as any).low * 1000).toISOString() : undefined,
1388 | uncertainty: update.departure.uncertainty,
1389 | } : undefined,
1390 | scheduleRelationship: update.scheduleRelationship,
1391 | })) : [],
1392 | timestamp: tripUpdate.timestamp ? new Date(typeof tripUpdate.timestamp === 'number' ? tripUpdate.timestamp * 1000 : (tripUpdate.timestamp as any).low * 1000).toISOString() : undefined,
1393 | delay: tripUpdate.delay,
1394 | }
1395 | };
1396 | }).filter(Boolean);
1397 |
1398 | // Cache the result
1399 | gtfsCache.tripUpdates.set(cacheKey, {
1400 | data: tripUpdatesData,
1401 | timestamp: Date.now(),
1402 | });
1403 | } catch (error) {
1404 | // If trip updates are not available, set to empty array
1405 | tripUpdatesData = [];
1406 |
1407 | // Still cache the empty result to avoid repeated failed requests
1408 | gtfsCache.tripUpdates.set(cacheKey, {
1409 | data: tripUpdatesData,
1410 | timestamp: Date.now(),
1411 | });
1412 |
1413 | console.error(`Error fetching trip updates for ${provider}${category ? `, category: ${category}` : ''}:`, error);
1414 | }
1415 | }
1416 |
1417 | // Get stop information
1418 | const stops = gtfsStaticData.stops || [];
1419 | const stop = stops.find((s: any) => s.stop_id === stop_id);
1420 |
1421 | if (!stop) {
1422 | return {
1423 | content: [
1424 | {
1425 | type: 'text',
1426 | text: JSON.stringify({
1427 | success: false,
1428 | message: `Stop ID ${stop_id} not found for provider: ${provider}${category ? `, category: ${category}` : ''}`,
1429 | valid_stop_ids: stops.map((s: any) => s.stop_id).slice(0, 10),
1430 | total_stops: stops.length,
1431 | }, null, 2),
1432 | },
1433 | ],
1434 | };
1435 | }
1436 |
1437 | // Filter trip updates for the specified stop
1438 | const arrivalsForStop = [];
1439 |
1440 | for (const entity of tripUpdatesData || []) {
1441 | if (!entity?.tripUpdate?.stopTimeUpdate) continue;
1442 |
1443 | // Find updates for this stop
1444 | const stopUpdates = entity.tripUpdate.stopTimeUpdate.filter((update: any) =>
1445 | update.stopId === stop_id
1446 | );
1447 |
1448 | if (stopUpdates.length === 0) continue;
1449 |
1450 | // Skip if route_id filter is provided and doesn't match
1451 | if (route_id && entity.tripUpdate.trip?.routeId !== route_id) continue;
1452 |
1453 | // Get route information
1454 | const routes = gtfsStaticData.routes || [];
1455 | const route = routes.find((r: any) => r.route_id === entity.tripUpdate.trip?.routeId);
1456 |
1457 | // Add to arrivals list
1458 | for (const update of stopUpdates) {
1459 | arrivalsForStop.push({
1460 | trip_id: entity.tripUpdate.trip?.tripId,
1461 | route_id: entity.tripUpdate.trip?.routeId,
1462 | route_short_name: route?.route_short_name,
1463 | route_long_name: route?.route_long_name,
1464 | direction_id: entity.tripUpdate.trip?.directionId,
1465 | arrival_time: update.arrival?.time,
1466 | arrival_delay: update.arrival?.delay,
1467 | departure_time: update.departure?.time,
1468 | departure_delay: update.departure?.delay,
1469 | stop_sequence: update.stopSequence,
1470 | schedule_relationship: update.scheduleRelationship,
1471 | });
1472 | }
1473 | }
1474 |
1475 | // Sort by arrival time
1476 | arrivalsForStop.sort((a: any, b: any) => {
1477 | const timeA = a.arrival_time || a.departure_time || '';
1478 | const timeB = b.arrival_time || b.departure_time || '';
1479 | return timeA.localeCompare(timeB);
1480 | });
1481 |
1482 | // Limit results
1483 | const limitedArrivals = arrivalsForStop.slice(0, limit);
1484 |
1485 | // Calculate time until arrival
1486 | const now = Date.now();
1487 | const arrivalsWithCountdown = limitedArrivals.map((arrival: any) => {
1488 | const arrivalTime = arrival.arrival_time ? new Date(arrival.arrival_time).getTime() : null;
1489 | const departureTime = arrival.departure_time ? new Date(arrival.departure_time).getTime() : null;
1490 | const nextTime = arrivalTime || departureTime;
1491 |
1492 | let minutesUntil = null;
1493 | if (nextTime) {
1494 | minutesUntil = Math.round((nextTime - now) / (60 * 1000));
1495 | }
1496 |
1497 | return {
1498 | ...arrival,
1499 | minutes_until_arrival: minutesUntil,
1500 | };
1501 | });
1502 |
1503 | return {
1504 | content: [
1505 | {
1506 | type: 'text',
1507 | text: JSON.stringify({
1508 | success: true,
1509 | message: `Successfully retrieved arrivals for stop: ${stop_id} (${stop.stop_name})`,
1510 | stop: stop,
1511 | arrivals: arrivalsWithCountdown,
1512 | count: arrivalsWithCountdown.length,
1513 | current_time: new Date().toISOString(),
1514 | 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,
1515 | }, null, 2),
1516 | },
1517 | ],
1518 | };
1519 | } catch (error) {
1520 | // Check if it's an axios error with response data
1521 | const axiosError = error as any;
1522 | const statusCode = axiosError?.response?.status;
1523 | const responseData = axiosError?.response?.data;
1524 |
1525 | return {
1526 | content: [
1527 | {
1528 | type: 'text',
1529 | text: JSON.stringify({
1530 | success: false,
1531 | message: 'Failed to get transit arrivals',
1532 | error: error instanceof Error ? error.message : 'Unknown error',
1533 | status_code: statusCode,
1534 | api_url: `${API_BASE_URL}${GTFS_TRIP_UPDATES_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`,
1535 | response_data: responseData,
1536 | provider_info: {
1537 | provider: provider,
1538 | category: category,
1539 | valid_providers: VALID_PROVIDERS,
1540 | valid_categories: PRASARANA_CATEGORIES
1541 | },
1542 | note: "If you're getting a 404 error, please check that the provider and category are correct. For Prasarana, a valid category is required."
1543 | }, null, 2),
1544 | },
1545 | ],
1546 | };
1547 | }
1548 | }
1549 | );
1550 |
1551 | // Search transit stops by location name
1552 | server.tool(
1553 | prefixToolName('search_transit_stops_by_location'),
1554 | '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.',
1555 | {
1556 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana", or common names like "rapid penang")'),
1557 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
1558 | location: z.string().describe('Location name to search for (e.g., "KLCC", "KL Sentral", "Penang Airport")'),
1559 | country: z.string().optional().describe('Country code to limit geocoding results (default: "my" for Malaysia)'),
1560 | limit: z.number().optional().describe('Maximum number of stops to return (default: 5)'),
1561 | max_distance: z.number().optional().describe('Maximum distance in kilometers (default: 5)'),
1562 | include_arrivals: z.boolean().optional().describe('Whether to include upcoming arrivals for each stop (default: true)'),
1563 | arrivals_limit: z.number().optional().describe('Maximum number of arrivals to include per stop (default: 3)'),
1564 | },
1565 | async ({ provider, category, location, country = 'my', limit = 5, max_distance = 5, include_arrivals = true, arrivals_limit = 3 }) => {
1566 | // Store normalized values at function scope so they're available in catch block
1567 | let normalizedProvider = provider;
1568 | let normalizedCategory = category;
1569 |
1570 | try {
1571 | // If provider looks like prasarana but no category is provided, set a default category
1572 | // This helps users who don't specify a category in their query
1573 | if ((provider.toLowerCase() === 'prasarana' || provider.toLowerCase().includes('rapid')) && !category) {
1574 | // Analyze the location query to determine if it's likely a bus or rail search
1575 | const locationLower = location.toLowerCase();
1576 |
1577 | // Check if the location contains keywords suggesting rail/LRT/MRT
1578 | const railKeywords = ['lrt', 'mrt', 'monorail', 'train', 'station', 'rail', 'kelana jaya', 'ampang', 'sri petaling'];
1579 | const isBusKeyword = locationLower.includes('bus') || locationLower.includes('stop');
1580 | const isRailKeyword = railKeywords.some(keyword => locationLower.includes(keyword));
1581 |
1582 | if (isRailKeyword && !isBusKeyword) {
1583 | // If location suggests rail and not bus, use rail category
1584 | category = 'rapid-rail-kl';
1585 | } else {
1586 | // Default to bus if not clearly rail or if both bus and rail are mentioned
1587 | category = 'rapid-bus-kl';
1588 | }
1589 | }
1590 |
1591 | // Step 1: Normalize provider and category first
1592 | const normalized = normalizeProviderAndCategory(provider, category);
1593 |
1594 | // Update function scope variables for catch block
1595 | normalizedProvider = normalized.provider;
1596 | normalizedCategory = normalized.category;
1597 |
1598 | // If there's an error, return it
1599 | if (normalized.error) {
1600 | return {
1601 | content: [
1602 | {
1603 | type: 'text',
1604 | text: JSON.stringify({
1605 | success: false,
1606 | message: normalized.error,
1607 | valid_providers: VALID_PROVIDERS,
1608 | valid_categories: PRASARANA_CATEGORIES,
1609 | common_names: Object.keys(PROVIDER_MAPPINGS),
1610 | example: normalized.provider === 'prasarana' ? {
1611 | provider: 'prasarana',
1612 | category: 'rapid-rail-kl'
1613 | } : undefined
1614 | }, null, 2),
1615 | },
1616 | ],
1617 | };
1618 | }
1619 |
1620 | // Step 2: Geocode the location name to coordinates
1621 | console.log(`Attempting to geocode location: ${location}`);
1622 | let coordinates = await geocodeLocation(location, country);
1623 |
1624 | // If initial geocoding fails, try with additional context
1625 | if (!coordinates) {
1626 | console.log(`Geocoding failed for "${location}", trying with additional context...`);
1627 |
1628 | // Try with state/city context for Malaysian locations
1629 | const locationVariations = [
1630 | // Add full country name
1631 | `${location}, Malaysia`,
1632 | // Add common Malaysian states if not already in the query
1633 | ...(!/penang|pulau pinang/i.test(location) ? [`${location}, Penang`, `${location}, Pulau Pinang`] : []),
1634 | ...(!/selangor/i.test(location) ? [`${location}, Selangor`] : []),
1635 | ...(!/kuala lumpur|kl/i.test(location) ? [`${location}, Kuala Lumpur`, `${location}, KL`] : []),
1636 | ...(!/johor/i.test(location) ? [`${location}, Johor`] : []),
1637 | // Try with common prefixes for condos/apartments
1638 | ...(!/condo|condominium|apartment|residence|residency|heights|court|villa|garden|park/i.test(location) ?
1639 | [`${location} Condominium`, `${location} Residence`, `${location} Apartment`] : [])
1640 | ];
1641 |
1642 | // Try each variation until we get coordinates
1643 | for (const variation of locationVariations) {
1644 | console.log(`Trying variation: "${variation}"`);
1645 | coordinates = await geocodeLocation(variation, country);
1646 | if (coordinates) {
1647 | console.log(`Successfully geocoded with variation: "${variation}"`);
1648 | break;
1649 | }
1650 | }
1651 | }
1652 |
1653 | // If all geocoding attempts fail, return error
1654 | if (!coordinates) {
1655 | return {
1656 | content: [
1657 | {
1658 | type: 'text',
1659 | text: JSON.stringify({
1660 | success: false,
1661 | message: `Could not geocode location: "${location}". Please try a different location name or provide more specific details.`,
1662 | location,
1663 | country,
1664 | provider_info: {
1665 | provider: normalizedProvider,
1666 | category: normalizedCategory,
1667 | valid_providers: VALID_PROVIDERS,
1668 | valid_categories: PRASARANA_CATEGORIES
1669 | },
1670 | suggestion: 'Please try a more specific address with city/state name, or use a nearby landmark.'
1671 | }, null, 2),
1672 | },
1673 | ],
1674 | };
1675 | }
1676 |
1677 | // Use normalized values for provider and category
1678 | provider = normalized.provider;
1679 | category = normalized.category;
1680 |
1681 | // Build cache key
1682 | const cacheKey = `${provider}-${category || 'default'}`;
1683 |
1684 | // Get static GTFS data
1685 | let gtfsStaticData;
1686 | if (gtfsCache.static.has(cacheKey)) {
1687 | const cached = gtfsCache.static.get(cacheKey)!;
1688 |
1689 | // Use cached data if not expired
1690 | if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) {
1691 | gtfsStaticData = cached.data;
1692 | }
1693 | }
1694 |
1695 | // If no cached data, fetch and parse GTFS static data
1696 | if (!gtfsStaticData) {
1697 | // Build URL
1698 | let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`;
1699 |
1700 | if (normalizedCategory) {
1701 | url += `?category=${normalizedCategory}`;
1702 | }
1703 |
1704 | // Trailing slash already added
1705 |
1706 | // Download ZIP file
1707 | const response = await axios.get(url, { responseType: 'arraybuffer' });
1708 |
1709 | // Parse GTFS data
1710 | gtfsStaticData = await parseGtfsStaticZip(Buffer.from(response.data));
1711 |
1712 | // Cache the result
1713 | gtfsCache.static.set(cacheKey, {
1714 | data: gtfsStaticData,
1715 | timestamp: Date.now(),
1716 | });
1717 | }
1718 |
1719 | // Step 3: Extract stops from GTFS data
1720 | const stops = gtfsStaticData.stops || [];
1721 |
1722 | if (stops.length === 0) {
1723 | return {
1724 | content: [
1725 | {
1726 | type: 'text',
1727 | text: JSON.stringify({
1728 | success: false,
1729 | message: `No stops found for provider: ${provider}${category ? `, category: ${category}` : ''}`,
1730 | provider: provider,
1731 | category: category,
1732 | }, null, 2),
1733 | },
1734 | ],
1735 | };
1736 | }
1737 |
1738 | // Step 4: Calculate distances from user location to each stop
1739 | const stopsWithDistance = stops.map((stop: any) => {
1740 | // Skip stops without coordinates
1741 | if (!stop.stop_lat || !stop.stop_lon) {
1742 | return null;
1743 | }
1744 |
1745 | const distance = haversineDistance(
1746 | coordinates.lat,
1747 | coordinates.lon,
1748 | parseFloat(stop.stop_lat),
1749 | parseFloat(stop.stop_lon)
1750 | );
1751 |
1752 | return {
1753 | ...stop,
1754 | distance_km: distance,
1755 | distance_m: Math.round(distance * 1000),
1756 | };
1757 | }).filter(Boolean);
1758 |
1759 | // Step 5: Filter stops by max distance and sort by proximity
1760 | const nearbyStops = stopsWithDistance
1761 | .filter((stop: any) => stop.distance_km <= max_distance)
1762 | .sort((a: any, b: any) => a.distance_km - b.distance_km)
1763 | .slice(0, limit);
1764 |
1765 | if (nearbyStops.length === 0) {
1766 | return {
1767 | content: [
1768 | {
1769 | type: 'text',
1770 | text: JSON.stringify({
1771 | success: false,
1772 | message: `No stops found within ${max_distance} km of "${location}"`,
1773 | location,
1774 | coordinates,
1775 | provider: provider,
1776 | category: category,
1777 | max_distance,
1778 | suggestion: 'Try increasing the max_distance parameter or searching for a different location.',
1779 | }, null, 2),
1780 | },
1781 | ],
1782 | };
1783 | }
1784 |
1785 | // Step 6: If requested, get real-time arrivals for each stop
1786 | let stopsWithArrivals = nearbyStops;
1787 |
1788 | if (include_arrivals) {
1789 | // Get trip updates data (for real-time arrivals)
1790 | let tripUpdatesData: any[] = [];
1791 | if (gtfsCache.tripUpdates.has(cacheKey)) {
1792 | const cached = gtfsCache.tripUpdates.get(cacheKey)!;
1793 |
1794 | // Use cached data if not expired
1795 | if (Date.now() - cached.timestamp < TRIP_UPDATES_CACHE_EXPIRY) {
1796 | tripUpdatesData = cached.data;
1797 | }
1798 | }
1799 |
1800 | // If no cached trip updates data, fetch and parse GTFS trip updates
1801 | if (!tripUpdatesData || tripUpdatesData.length === 0) {
1802 | // Build URL
1803 | let url = `${API_BASE_URL}${GTFS_TRIP_UPDATES_ENDPOINT}/${normalizedProvider}/`;
1804 |
1805 | if (normalizedCategory) {
1806 | url += `?category=${normalizedCategory}`;
1807 | }
1808 |
1809 | // Trailing slash already added
1810 |
1811 | try {
1812 | // Download Protocol Buffer data
1813 | const response = await axios.get(url, { responseType: 'arraybuffer' });
1814 |
1815 | // Parse Protocol Buffer data
1816 | const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
1817 | new Uint8Array(response.data)
1818 | );
1819 |
1820 | // Convert to plain JavaScript object
1821 | tripUpdatesData = feed.entity.map(entity => {
1822 | if (!entity.tripUpdate) {
1823 | return null;
1824 | }
1825 |
1826 | const tripUpdate = entity.tripUpdate;
1827 | return {
1828 | id: entity.id,
1829 | tripUpdate: {
1830 | trip: tripUpdate.trip ? {
1831 | tripId: tripUpdate.trip.tripId,
1832 | routeId: tripUpdate.trip.routeId,
1833 | directionId: tripUpdate.trip.directionId,
1834 | startTime: tripUpdate.trip.startTime,
1835 | startDate: tripUpdate.trip.startDate,
1836 | scheduleRelationship: tripUpdate.trip.scheduleRelationship,
1837 | } : undefined,
1838 | stopTimeUpdate: tripUpdate.stopTimeUpdate ? tripUpdate.stopTimeUpdate.map(update => ({
1839 | stopSequence: update.stopSequence,
1840 | stopId: update.stopId,
1841 | arrival: update.arrival ? {
1842 | delay: update.arrival.delay,
1843 | time: update.arrival.time ? new Date(typeof update.arrival.time === 'number' ? update.arrival.time * 1000 : (update.arrival.time as any).low * 1000).toISOString() : undefined,
1844 | uncertainty: update.arrival.uncertainty,
1845 | } : undefined,
1846 | departure: update.departure ? {
1847 | delay: update.departure.delay,
1848 | time: update.departure.time ? new Date(typeof update.departure.time === 'number' ? update.departure.time * 1000 : (update.departure.time as any).low * 1000).toISOString() : undefined,
1849 | uncertainty: update.departure.uncertainty,
1850 | } : undefined,
1851 | scheduleRelationship: update.scheduleRelationship,
1852 | })) : [],
1853 | timestamp: tripUpdate.timestamp ? new Date(typeof tripUpdate.timestamp === 'number' ? tripUpdate.timestamp * 1000 : (tripUpdate.timestamp as any).low * 1000).toISOString() : undefined,
1854 | delay: tripUpdate.delay,
1855 | }
1856 | };
1857 | }).filter(Boolean);
1858 |
1859 | // Cache the result
1860 | gtfsCache.tripUpdates.set(cacheKey, {
1861 | data: tripUpdatesData,
1862 | timestamp: Date.now(),
1863 | });
1864 | } catch (error) {
1865 | // If trip updates are not available, set to empty array
1866 | tripUpdatesData = [];
1867 |
1868 | // Still cache the empty result to avoid repeated failed requests
1869 | gtfsCache.tripUpdates.set(cacheKey, {
1870 | data: tripUpdatesData,
1871 | timestamp: Date.now(),
1872 | });
1873 |
1874 | console.error(`Error fetching trip updates for ${provider}${category ? `, category: ${category}` : ''}:`, error);
1875 | }
1876 | }
1877 |
1878 | // Get routes information for better display
1879 | const routes = gtfsStaticData.routes || [];
1880 |
1881 | // Add arrivals to each stop
1882 | stopsWithArrivals = nearbyStops.map((stop: any) => {
1883 | // Find arrivals for this stop
1884 | const arrivalsForStop: any[] = [];
1885 |
1886 | for (const entity of tripUpdatesData || []) {
1887 | if (!entity?.tripUpdate?.stopTimeUpdate) continue;
1888 |
1889 | // Find updates for this stop
1890 | const stopUpdates = entity.tripUpdate.stopTimeUpdate.filter((update: any) =>
1891 | update.stopId === stop.stop_id
1892 | );
1893 |
1894 | if (stopUpdates.length === 0) continue;
1895 |
1896 | // Get route information
1897 | const route = routes.find((r: any) => r.route_id === entity.tripUpdate.trip?.routeId);
1898 |
1899 | // Add to arrivals list
1900 | for (const update of stopUpdates) {
1901 | arrivalsForStop.push({
1902 | trip_id: entity.tripUpdate.trip?.tripId,
1903 | route_id: entity.tripUpdate.trip?.routeId,
1904 | route_short_name: route?.route_short_name,
1905 | route_long_name: route?.route_long_name,
1906 | direction_id: entity.tripUpdate.trip?.directionId,
1907 | arrival_time: update.arrival?.time,
1908 | arrival_delay: update.arrival?.delay,
1909 | departure_time: update.departure?.time,
1910 | departure_delay: update.departure?.delay,
1911 | stop_sequence: update.stopSequence,
1912 | schedule_relationship: update.scheduleRelationship,
1913 | });
1914 | }
1915 | }
1916 |
1917 | // Sort by arrival time
1918 | arrivalsForStop.sort((a: any, b: any) => {
1919 | const timeA = a.arrival_time || a.departure_time || '';
1920 | const timeB = b.arrival_time || b.departure_time || '';
1921 | return timeA.localeCompare(timeB);
1922 | });
1923 |
1924 | // Limit results
1925 | const limitedArrivals = arrivalsForStop.slice(0, arrivals_limit);
1926 |
1927 | // Calculate time until arrival
1928 | const now = Date.now();
1929 | const arrivalsWithCountdown = limitedArrivals.map((arrival: any) => {
1930 | const arrivalTime = arrival.arrival_time ? new Date(arrival.arrival_time).getTime() : null;
1931 | const departureTime = arrival.departure_time ? new Date(arrival.departure_time).getTime() : null;
1932 | const nextTime = arrivalTime || departureTime;
1933 |
1934 | let minutesUntil = null;
1935 | if (nextTime) {
1936 | minutesUntil = Math.round((nextTime - now) / (60 * 1000));
1937 | }
1938 |
1939 | return {
1940 | ...arrival,
1941 | minutes_until_arrival: minutesUntil,
1942 | };
1943 | });
1944 |
1945 | return {
1946 | ...stop,
1947 | upcoming_arrivals: arrivalsWithCountdown,
1948 | has_realtime_data: arrivalsWithCountdown.length > 0,
1949 | };
1950 | });
1951 | }
1952 |
1953 | return {
1954 | content: [
1955 | {
1956 | type: 'text',
1957 | text: JSON.stringify({
1958 | success: true,
1959 | message: `Found ${stopsWithArrivals.length} stops near "${location}"`,
1960 | location,
1961 | coordinates,
1962 | provider,
1963 | category,
1964 | stops: stopsWithArrivals,
1965 | count: stopsWithArrivals.length,
1966 | include_arrivals,
1967 | current_time: new Date().toISOString(),
1968 | search_parameters: {
1969 | max_distance,
1970 | limit,
1971 | arrivals_limit: include_arrivals ? arrivals_limit : undefined,
1972 | },
1973 | note: stopsWithArrivals.some((s: any) => s.has_realtime_data) ? undefined : `No real-time arrival data available for these stops. ${REALTIME_DATA_NOTE}`,
1974 | }, null, 2),
1975 | },
1976 | ],
1977 | };
1978 | } catch (error) {
1979 | // Check if it's an axios error with response data
1980 | const axiosError = error as any;
1981 | const statusCode = axiosError?.response?.status;
1982 | const responseData = axiosError?.response?.data;
1983 |
1984 | // Try to parse the Buffer data if present
1985 | let parsedResponseData = responseData;
1986 | if (responseData && responseData.type === 'Buffer' && Array.isArray(responseData.data)) {
1987 | try {
1988 | const buffer = Buffer.from(responseData.data);
1989 | parsedResponseData = JSON.parse(buffer.toString());
1990 | } catch (parseError) {
1991 | console.error('Error parsing buffer data:', parseError);
1992 | }
1993 | }
1994 |
1995 | return {
1996 | content: [
1997 | {
1998 | type: 'text',
1999 | text: JSON.stringify({
2000 | success: false,
2001 | message: 'Failed to search transit stops by location',
2002 | error: error instanceof Error ? error.message : 'Unknown error',
2003 | status_code: statusCode,
2004 | response_data: parsedResponseData,
2005 | location,
2006 | provider_info: {
2007 | provider: provider,
2008 | category: category,
2009 | valid_providers: VALID_PROVIDERS,
2010 | valid_categories: PRASARANA_CATEGORIES
2011 | },
2012 | 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.'
2013 | }, null, 2),
2014 | },
2015 | ],
2016 | };
2017 | }
2018 | }
2019 | );
2020 |
2021 | // Find nearest transit stops
2022 | server.tool(
2023 | prefixToolName('find_nearest_transit_stops'),
2024 | '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?"',
2025 | {
2026 | provider: z.string().describe('Provider name (e.g., "mybas-johor", "ktmb", "prasarana", or common names like "rapid penang")'),
2027 | category: z.string().optional().describe('Category for Prasarana data (required only for prasarana provider)'),
2028 | latitude: z.number().describe('Latitude of the user\'s location'),
2029 | longitude: z.number().describe('Longitude of the user\'s location'),
2030 | limit: z.number().optional().describe('Maximum number of stops to return (default: 5)'),
2031 | max_distance: z.number().optional().describe('Maximum distance in kilometers (default: 5)'),
2032 | },
2033 | async ({ provider, category, latitude, longitude, limit = 5, max_distance = 5 }) => {
2034 | try {
2035 | // Normalize provider and category
2036 | const normalized = normalizeProviderAndCategory(provider, category);
2037 |
2038 | // If there's an error, return it
2039 | if (normalized.error) {
2040 | return {
2041 | content: [
2042 | {
2043 | type: 'text',
2044 | text: JSON.stringify({
2045 | success: false,
2046 | message: normalized.error,
2047 | valid_providers: VALID_PROVIDERS,
2048 | valid_categories: PRASARANA_CATEGORIES,
2049 | common_names: Object.keys(PROVIDER_MAPPINGS),
2050 | example: normalized.provider === 'prasarana' ? {
2051 | provider: 'prasarana',
2052 | category: 'rapid-rail-kl'
2053 | } : undefined
2054 | }, null, 2),
2055 | },
2056 | ],
2057 | };
2058 | }
2059 |
2060 | // Use normalized values
2061 | const normalizedProvider = normalized.provider;
2062 | const normalizedCategory = normalized.category;
2063 |
2064 | // Build cache key
2065 | const cacheKey = `${normalizedProvider}-${normalizedCategory || 'default'}`;
2066 |
2067 | // Check if we have cached GTFS data
2068 | let gtfsData;
2069 | if (gtfsCache.static.has(cacheKey)) {
2070 | const cached = gtfsCache.static.get(cacheKey)!;
2071 |
2072 | // Use cached data if not expired
2073 | if (Date.now() - cached.timestamp < STATIC_CACHE_EXPIRY) {
2074 | gtfsData = cached.data;
2075 | }
2076 | }
2077 |
2078 | // If no cached data, fetch and parse GTFS data
2079 | if (!gtfsData) {
2080 | // Build URL
2081 | let url = `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${normalizedProvider}/`;
2082 |
2083 | if (normalizedCategory) {
2084 | url += `?category=${normalizedCategory}`;
2085 | }
2086 |
2087 | // Download ZIP file
2088 | const response = await axios.get(url, { responseType: 'arraybuffer' });
2089 |
2090 | // Parse GTFS data
2091 | gtfsData = await parseGtfsStaticZip(Buffer.from(response.data));
2092 |
2093 | // Cache the result
2094 | gtfsCache.static.set(cacheKey, {
2095 | data: gtfsData,
2096 | timestamp: Date.now(),
2097 | });
2098 | }
2099 |
2100 | // Extract stops data
2101 | const stops = gtfsData.stops || [];
2102 |
2103 | // Calculate distance for each stop
2104 | const stopsWithDistance = stops.map((stop: any) => {
2105 | // Skip stops without lat/lon
2106 | if (!stop.stop_lat || !stop.stop_lon) {
2107 | return { ...stop, distance: Infinity };
2108 | }
2109 |
2110 | // Calculate distance using Haversine formula
2111 | const stopLat = parseFloat(stop.stop_lat);
2112 | const stopLon = parseFloat(stop.stop_lon);
2113 |
2114 | // Haversine formula
2115 | const R = 6371; // Earth radius in km
2116 | const dLat = (stopLat - latitude) * Math.PI / 180;
2117 | const dLon = (stopLon - longitude) * Math.PI / 180;
2118 | const a =
2119 | Math.sin(dLat/2) * Math.sin(dLat/2) +
2120 | Math.cos(latitude * Math.PI / 180) * Math.cos(stopLat * Math.PI / 180) *
2121 | Math.sin(dLon/2) * Math.sin(dLon/2);
2122 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
2123 | const distance = R * c; // Distance in km
2124 |
2125 | return { ...stop, distance };
2126 | });
2127 |
2128 | // Filter by max distance and sort by distance
2129 | const nearestStops = stopsWithDistance
2130 | .filter((stop: any) => stop.distance <= max_distance)
2131 | .sort((a: any, b: any) => a.distance - b.distance)
2132 | .slice(0, limit);
2133 |
2134 | // Format distances to be more readable
2135 | const formattedStops = nearestStops.map((stop: any) => ({
2136 | ...stop,
2137 | distance_km: parseFloat(stop.distance.toFixed(2)),
2138 | distance_m: parseFloat((stop.distance * 1000).toFixed(0)),
2139 | }));
2140 |
2141 | return {
2142 | content: [
2143 | {
2144 | type: 'text',
2145 | text: JSON.stringify({
2146 | success: true,
2147 | message: `Successfully found nearest stops for provider: ${provider}${category ? `, category: ${category}` : ''}`,
2148 | data: formattedStops,
2149 | count: formattedStops.length,
2150 | user_location: { latitude, longitude },
2151 | provider_info: { provider, category },
2152 | note: REALTIME_DATA_NOTE,
2153 | }, null, 2),
2154 | },
2155 | ],
2156 | };
2157 | } catch (error) {
2158 | // Check if it's an axios error with response data
2159 | const axiosError = error as any;
2160 | const statusCode = axiosError?.response?.status;
2161 | const responseData = axiosError?.response?.data;
2162 |
2163 | return {
2164 | content: [
2165 | {
2166 | type: 'text',
2167 | text: JSON.stringify({
2168 | success: false,
2169 | message: 'Failed to find nearest transit stops',
2170 | error: error instanceof Error ? error.message : 'Unknown error',
2171 | status_code: statusCode,
2172 | api_url: `${API_BASE_URL}${GTFS_STATIC_ENDPOINT}/${provider}${category ? `?category=${category}` : ''}`,
2173 | response_data: responseData,
2174 | provider_info: {
2175 | provider: provider,
2176 | category: category,
2177 | valid_providers: VALID_PROVIDERS,
2178 | valid_categories: PRASARANA_CATEGORIES
2179 | },
2180 | note: COMBINED_ERROR_NOTE,
2181 | }, null, 2),
2182 | },
2183 | ],
2184 | };
2185 | }
2186 | }
2187 | );
2188 | }
2189 |
```