@siva-sub/mcp-public-transport
Version:
A Model Context Protocol server for Singapore transport data with real-time information and routing
409 lines (408 loc) • 18.5 kB
JavaScript
import { BaseTool } from '../base.js';
import { APIError, ValidationError } from '../../utils/errors.js';
import { logger } from '../../utils/logger.js';
export class LocationSearchTool extends BaseTool {
oneMapService;
postalCodeService;
timeService;
fuzzySearchService;
constructor(oneMapService, postalCodeService, timeService, fuzzySearchService) {
super();
this.oneMapService = oneMapService;
this.postalCodeService = postalCodeService;
this.timeService = timeService;
this.fuzzySearchService = fuzzySearchService;
}
getDefinitions() {
return [
{
name: 'search_location',
description: 'Search for locations in Singapore using addresses, postal codes, landmarks, or building names. Supports fuzzy search and intelligent query analysis.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query - can be address, postal code, MRT station, landmark, or building name',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return (1-20)',
minimum: 1,
maximum: 20,
default: 5,
},
enableFuzzySearch: {
type: 'boolean',
description: 'Enable fuzzy matching for typos and variations',
default: true,
},
includeNearbyInfo: {
type: 'boolean',
description: 'Include nearby amenities and transport information',
default: false,
},
userLocation: {
type: 'object',
description: 'User location for proximity-based ranking',
properties: {
latitude: { type: 'number' },
longitude: { type: 'number' },
},
},
},
required: ['query'],
},
},
{
name: 'resolve_postal_code',
description: 'Resolve a Singapore postal code to detailed location information with high accuracy.',
inputSchema: {
type: 'object',
properties: {
postalCode: {
type: 'string',
description: 'Singapore 6-digit postal code to resolve',
pattern: '^\\d{6}$',
},
includeNearbyInfo: {
type: 'boolean',
description: 'Include nearby transport and amenities',
default: false,
},
},
required: ['postalCode'],
},
},
{
name: 'reverse_geocode',
description: 'Get address and location information from latitude/longitude coordinates.',
inputSchema: {
type: 'object',
properties: {
latitude: {
type: 'number',
description: 'Latitude coordinate (WGS84)',
minimum: 1.0,
maximum: 1.5,
},
longitude: {
type: 'number',
description: 'Longitude coordinate (WGS84)',
minimum: 103.0,
maximum: 104.5,
},
radius: {
type: 'number',
description: 'Search radius in meters (0-500)',
minimum: 0,
maximum: 500,
default: 100,
},
includeNearbyAmenities: {
type: 'boolean',
description: 'Include nearby amenities and transport',
default: false,
},
},
required: ['latitude', 'longitude'],
},
},
];
}
canHandle(toolName) {
return ['search_location', 'resolve_postal_code', 'reverse_geocode'].includes(toolName);
}
async execute(toolName, args) {
try {
switch (toolName) {
case 'search_location':
return await this.searchLocation(args);
case 'resolve_postal_code':
return await this.resolvePostalCode(args);
case 'reverse_geocode':
return await this.reverseGeocode(args);
default:
throw new ValidationError(`Unknown tool: ${toolName}`);
}
}
catch (error) {
logger.error(`Location tool execution failed: ${toolName}`, error);
return this.formatError(error, toolName);
}
}
async searchLocation(args) {
const { query, maxResults = 5, enableFuzzySearch = true, includeNearbyInfo = false, userLocation, } = args;
if (!query?.trim()) {
throw new ValidationError('Query cannot be empty');
}
logger.info(`Searching for location: ${query}`);
const results = [];
const searchStrategiesUsed = [];
const timeInfo = this.timeService.getCurrentTime();
try {
// Strategy 1: Check if it's a postal code
const postalCode = this.postalCodeService.extractPostalCodeFromText(query);
if (postalCode) {
logger.debug(`Detected postal code: ${postalCode}`);
const postalResult = await this.postalCodeService.getLocationFromPostalCode(postalCode);
if (postalResult) {
results.push(postalResult);
searchStrategiesUsed.push('postal_code');
}
}
// Strategy 2: OneMap search with authentication
if (results.length < maxResults) {
logger.debug('Performing OneMap search');
const oneMapResults = await this.oneMapService.geocodeWithAuth(query);
for (const omResult of oneMapResults.slice(0, maxResults - results.length)) {
const locationResult = this.convertOneMapToLocationResult(omResult);
// Avoid duplicates (especially postal code results)
const isDuplicate = results.some(existing => Math.abs(existing.coordinates.wgs84.lat - locationResult.coordinates.wgs84.lat) < 0.0001 &&
Math.abs(existing.coordinates.wgs84.lng - locationResult.coordinates.wgs84.lng) < 0.0001);
if (!isDuplicate) {
results.push(locationResult);
}
}
if (oneMapResults.length > 0) {
searchStrategiesUsed.push('onemap_geocoding');
}
}
// Strategy 3: Fuzzy search and suggestions (if enabled and few results)
if (enableFuzzySearch && results.length < 2) {
searchStrategiesUsed.push('fuzzy_search');
// Additional fuzzy logic could be implemented here
}
// Sort results by relevance (postal codes first, then by confidence)
results.sort((a, b) => {
// Postal codes get highest priority
if (a.locationTypes.includes('POSTAL_CODE') && !b.locationTypes.includes('POSTAL_CODE')) {
return -1;
}
if (b.locationTypes.includes('POSTAL_CODE') && !a.locationTypes.includes('POSTAL_CODE')) {
return 1;
}
// Then by confidence
return b.confidence - a.confidence;
});
// Generate suggestions for improvement
const suggestions = this.generateSearchSuggestions(query, results);
// Calculate overall confidence
const confidence = results.length > 0 ?
Math.max(...results.map(r => r.confidence)) : 0;
const response = {
query,
results: results.slice(0, maxResults),
suggestions,
confidence,
searchStrategiesUsed,
contextInfo: {
singaporeTime: timeInfo.formatted.full,
isBusinessHours: timeInfo.businessContext.isBusinessHours,
isRushHour: timeInfo.businessContext.isRushHour,
},
timestamp: new Date().toISOString(),
};
logger.info(`Location search completed: ${results.length} results found`);
return response;
}
catch (error) {
logger.error('Location search failed', error);
throw new APIError(`Location search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'SEARCH_FAILED');
}
}
async resolvePostalCode(args) {
const { postalCode, includeNearbyInfo = false } = args;
if (!this.postalCodeService.isValidSingaporePostalCode(postalCode)) {
throw new ValidationError(`Invalid Singapore postal code: ${postalCode}. Must be exactly 6 digits.`);
}
logger.info(`Resolving postal code: ${postalCode}`);
try {
const location = await this.postalCodeService.getLocationFromPostalCode(postalCode);
if (!location) {
return {
success: false,
error: `Postal code ${postalCode} not found`,
suggestions: [
'Verify the postal code is correct',
'Try searching for the building name or address instead',
],
};
}
const timeInfo = this.timeService.getCurrentTime();
const district = this.postalCodeService.getDistrictFromPostalCode(postalCode);
const areaType = this.postalCodeService.getAreaType(postalCode);
return {
success: true,
location,
metadata: {
district,
areaType,
confidence: location.confidence,
lastUpdated: location.metadata.lastUpdated,
},
contextInfo: {
singaporeTime: timeInfo.formatted.full,
isBusinessHours: timeInfo.businessContext.isBusinessHours,
isRushHour: timeInfo.businessContext.isRushHour,
},
timestamp: new Date().toISOString(),
};
}
catch (error) {
logger.error(`Postal code resolution failed: ${postalCode}`, error);
throw new APIError(`Failed to resolve postal code: ${error instanceof Error ? error.message : 'Unknown error'}`, 'POSTAL_CODE_FAILED');
}
}
async reverseGeocode(args) {
const { latitude, longitude, radius = 100, includeNearbyAmenities = false } = args;
// Validate Singapore coordinates
if (latitude < 1.0 || latitude > 1.5 || longitude < 103.0 || longitude > 104.5) {
throw new ValidationError('Coordinates must be within Singapore bounds');
}
logger.info(`Reverse geocoding: ${latitude}, ${longitude}`);
try {
// Use OneMap reverse geocoding API
const response = await this.oneMapService.client.get('/public/revgeocode', {
params: {
location: `${latitude},${longitude}`,
buffer: radius.toString(),
addressType: 'All',
},
headers: {
'Authorization': await this.oneMapService.ensureValidToken(),
},
});
const geocodeInfo = response.data.GeocodeInfo || [];
const timeInfo = this.timeService.getCurrentTime();
if (geocodeInfo.length === 0) {
return {
success: false,
message: 'No address found at the specified coordinates',
coordinates: { latitude, longitude },
searchRadius: radius,
};
}
const results = geocodeInfo.map((info) => ({
buildingName: info.BUILDINGNAME !== 'null' ? info.BUILDINGNAME : null,
block: info.BLOCK,
road: info.ROAD,
postalCode: info.POSTALCODE,
coordinates: {
latitude: parseFloat(info.LATITUDE),
longitude: parseFloat(info.LONGITUDE),
},
distance: this.calculateDistance(latitude, longitude, parseFloat(info.LATITUDE), parseFloat(info.LONGITUDE)),
}));
return {
success: true,
results,
searchLocation: { latitude, longitude },
searchRadius: radius,
contextInfo: {
singaporeTime: timeInfo.formatted.full,
isBusinessHours: timeInfo.businessContext.isBusinessHours,
isRushHour: timeInfo.businessContext.isRushHour,
},
timestamp: new Date().toISOString(),
};
}
catch (error) {
logger.error('Reverse geocoding failed', error);
throw new APIError(`Reverse geocoding failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'REVERSE_GEOCODE_FAILED');
}
}
convertOneMapToLocationResult(oneMapResult) {
const lat = parseFloat(oneMapResult.LATITUDE);
const lng = parseFloat(oneMapResult.LONGITUDE);
const x = parseFloat(oneMapResult.X);
const y = parseFloat(oneMapResult.Y);
const buildingName = oneMapResult.BUILDING && oneMapResult.BUILDING !== 'NIL'
? oneMapResult.BUILDING : undefined;
const blockNumber = oneMapResult.BLK_NO && oneMapResult.BLK_NO !== 'NIL'
? oneMapResult.BLK_NO : undefined;
const name = buildingName || oneMapResult.ROAD_NAME || 'Unknown Location';
const displayName = oneMapResult.ADDRESS || `${name}, Singapore`;
return {
id: `onemap_${oneMapResult.SEARCHVAL?.replace(/\s+/g, '_').toLowerCase()}`,
name,
displayName,
coordinates: {
wgs84: { lat, lng },
svy21: { x, y },
},
address: {
buildingName,
blockNumber,
streetName: oneMapResult.ROAD_NAME,
postalCode: oneMapResult.POSTAL,
formattedAddress: oneMapResult.ADDRESS,
},
locationTypes: this.determineLocationTypes(oneMapResult),
confidence: 0.85, // Good confidence for OneMap results
metadata: {
source: 'onemap',
lastUpdated: new Date().toISOString(),
isLandmark: !!buildingName,
searchStrategy: 'onemap_geocoding',
},
};
}
determineLocationTypes(oneMapResult) {
const types = [];
if (oneMapResult.POSTAL)
types.push('POSTAL_CODE');
if (oneMapResult.BUILDING && oneMapResult.BUILDING !== 'NIL')
types.push('BUILDING');
if (oneMapResult.ROAD_NAME)
types.push('STREET');
// Check for MRT/LRT stations
const searchVal = oneMapResult.SEARCHVAL?.toLowerCase() || '';
const building = oneMapResult.BUILDING?.toLowerCase() || '';
if (searchVal.includes('mrt') || building.includes('mrt'))
types.push('MRT_STATION');
if (searchVal.includes('lrt') || building.includes('lrt'))
types.push('LRT_STATION');
if (searchVal.includes('bus') || building.includes('interchange'))
types.push('BUS_INTERCHANGE');
return types.length > 0 ? types : ['GENERAL'];
}
generateSearchSuggestions(query, results) {
const suggestions = [];
if (results.length === 0) {
suggestions.push({
text: 'Try searching with a postal code (6 digits)',
type: 'expansion',
confidence: 0.8,
reason: 'Postal codes provide the most accurate results',
});
suggestions.push({
text: 'Include "MRT" or "LRT" for train stations',
type: 'expansion',
confidence: 0.7,
reason: 'Transport stations are common search targets',
});
}
else if (results.length === 1) {
suggestions.push({
text: 'Add more specific terms for additional results',
type: 'expansion',
confidence: 0.6,
reason: 'Broader search might find related locations',
});
}
return suggestions;
}
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3; // Earth's radius in metres
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c); // Distance in metres
}
}