@siva-sub/mcp-public-transport
Version:
A Model Context Protocol server for Singapore transport data with real-time information and routing
330 lines (329 loc) • 14.5 kB
JavaScript
"use strict";
/**
* Landmarks and Facilities Discovery Tool
* Uses OneMap Themes API to find landmarks, facilities, and points of interest
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.LandmarksDiscoveryTool = void 0;
const zod_1 = require("zod");
const base_js_1 = require("../base.js");
const validation_js_1 = require("../../utils/validation.js");
const logger_js_1 = require("../../utils/logger.js");
const LandmarkSearchInputSchema = zod_1.z.object({
location: zod_1.z.union([
zod_1.z.string().min(1, 'Location must be provided'),
zod_1.z.object({
postalCode: zod_1.z.string().regex(/^\d{6}$/, 'Postal code must be 6 digits'),
}),
zod_1.z.object({
latitude: zod_1.z.number().min(1.0).max(1.5),
longitude: zod_1.z.number().min(103.0).max(104.5),
name: zod_1.z.string().optional(),
}),
]),
radius: zod_1.z.number().min(100).max(5000).default(1000),
categories: zod_1.z.array(zod_1.z.string()).optional(),
facilityType: zod_1.z.string().optional(),
maxResults: zod_1.z.number().min(1).max(50).default(20),
});
class LandmarksDiscoveryTool extends base_js_1.BaseTool {
constructor(themesService, oneMapService) {
super();
this.themesService = themesService;
this.oneMapService = oneMapService;
}
getDefinitions() {
return [
{
name: 'find_landmarks_and_facilities',
description: 'Discover landmarks, facilities, and points of interest near a location using Singapore\'s comprehensive thematic data',
inputSchema: this.createSchema({
location: {
oneOf: [
{
type: 'string',
description: 'Location as address, landmark, or postal code (e.g., "Marina Bay", "Orchard Road", "828770")',
},
{
type: 'object',
properties: {
postalCode: {
type: 'string',
pattern: '^\\d{6}$',
description: 'Singapore postal code (6 digits)',
},
},
required: ['postalCode'],
description: 'Location as postal code object',
},
{
type: 'object',
properties: {
latitude: {
type: 'number',
minimum: 1.0,
maximum: 1.5,
description: 'Latitude coordinate',
},
longitude: {
type: 'number',
minimum: 103.0,
maximum: 104.5,
description: 'Longitude coordinate',
},
name: {
type: 'string',
description: 'Optional location name',
},
},
required: ['latitude', 'longitude'],
description: 'Location as coordinates',
},
],
description: 'Target location to search around',
},
radius: {
type: 'number',
minimum: 100,
maximum: 5000,
default: 1000,
description: 'Search radius in meters (100-5000m)',
},
categories: {
type: 'array',
items: {
type: 'string',
enum: ['education', 'health', 'community', 'recreation', 'transport', 'shopping', 'tourism', 'government', 'religious', 'sports'],
},
description: 'Filter by specific categories (optional)',
},
facilityType: {
type: 'string',
description: 'Search for specific facility types (e.g., "schools", "hospitals", "parks", "libraries")',
},
maxResults: {
type: 'number',
minimum: 1,
maximum: 50,
default: 20,
description: 'Maximum number of results to return per category',
},
}, ['location']),
},
];
}
canHandle(toolName) {
return toolName === 'find_landmarks_and_facilities';
}
async execute(toolName, args) {
const startTime = Date.now();
let apiCalls = 0;
let cacheHits = 0;
try {
const input = (0, validation_js_1.validateInput)(LandmarkSearchInputSchema, args);
logger_js_1.logger.info('Finding landmarks and facilities', {
location: input.location,
radius: input.radius,
categories: input.categories,
facilityType: input.facilityType
});
// Resolve the location
const location = await this.resolveLocation(input.location);
apiCalls++;
if (!location) {
return this.createErrorResponse('Could not resolve the specified location', startTime, apiCalls, cacheHits);
}
let searchResults = [];
// Search by specific facility type if provided
if (input.facilityType) {
searchResults = await this.themesService.findFacilitiesByType(input.facilityType, location.latitude, location.longitude, input.radius);
apiCalls += 3; // Estimate for theme searches
}
else {
// Search for landmarks near location
searchResults = await this.themesService.findLandmarksNear(location.latitude, location.longitude, input.radius, input.categories);
apiCalls += 5; // Estimate for theme searches
}
// Process and format results
const formattedResults = this.formatSearchResults(searchResults, input.maxResults || 20);
// Generate summary
const summary = this.generateSummary(formattedResults);
const processingTime = Date.now() - startTime;
return {
success: true,
location: {
name: location.name || 'Unknown Location',
coordinates: [location.latitude, location.longitude],
address: location.address,
},
searchRadius: input.radius || 1000,
results: formattedResults,
summary,
metadata: {
searchTime: processingTime,
apiCalls,
cacheHits,
},
};
}
catch (error) {
logger_js_1.logger.error(`Landmark search failed: ${toolName}`, error);
return this.createErrorResponse(error instanceof Error ? error.message : 'Unknown error occurred', startTime, apiCalls, cacheHits);
}
}
async resolveLocation(locationInput) {
try {
// Handle JSON string inputs (like the failing examples)
if (typeof locationInput === 'string') {
try {
const parsed = JSON.parse(locationInput);
if (parsed && typeof parsed === 'object') {
return this.resolveLocation(parsed);
}
}
catch {
// Continue with string geocoding
}
}
// Handle coordinate objects
if (locationInput && typeof locationInput === 'object' && 'latitude' in locationInput) {
const lat = parseFloat(locationInput.latitude);
const lng = parseFloat(locationInput.longitude);
// Validate Singapore coordinates
if (lat < 1.0 || lat > 1.5 || lng < 103.0 || lng > 104.5) {
logger_js_1.logger.warn('Coordinates outside Singapore bounds', { lat, lng });
return null;
}
return {
latitude: lat,
longitude: lng,
name: locationInput.name || 'Custom Location',
address: locationInput.name || `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
};
}
// Handle postal code objects
if (locationInput && typeof locationInput === 'object' && 'postalCode' in locationInput) {
const postalCode = locationInput.postalCode.toString();
if (!/^\d{6}$/.test(postalCode)) {
logger_js_1.logger.warn('Invalid postal code format', { postalCode });
return null;
}
const result = await this.oneMapService.geocode(postalCode);
if (result) {
return {
latitude: result.latitude,
longitude: result.longitude,
name: result.name || 'Unknown Location',
address: result.address || 'Unknown Address'
};
}
return null;
}
// Handle string inputs (addresses, landmarks, postal codes)
if (typeof locationInput === 'string') {
// Check if it's a 6-digit postal code
if (/^\d{6}$/.test(locationInput)) {
const result = await this.oneMapService.geocode(locationInput);
if (result) {
return {
latitude: result.latitude,
longitude: result.longitude,
name: result.name || 'Unknown Location',
address: result.address || 'Unknown Address'
};
}
return null;
}
// Otherwise treat as address/landmark
const result = await this.oneMapService.geocode(locationInput);
if (result) {
return {
latitude: result.latitude,
longitude: result.longitude,
name: result.name || 'Unknown Location',
address: result.address || 'Unknown Address'
};
}
return null;
}
logger_js_1.logger.warn('Unrecognized location input format', { locationInput });
return null;
}
catch (error) {
logger_js_1.logger.error('Location resolution failed', { error, input: locationInput });
return null;
}
}
formatSearchResults(searchResults, maxResults) {
return searchResults.map(result => ({
category: result.theme.category,
theme: result.theme.themeName,
facilities: result.features.slice(0, maxResults).map(feature => ({
name: feature.name,
description: feature.description,
distance: Math.round(feature.distance ?? 0),
coordinates: feature.coordinates,
category: feature.category,
owner: feature.owner,
website: feature.hyperlink,
additionalInfo: this.extractAdditionalInfo(feature.properties),
})),
totalCount: result.totalCount,
})).filter(result => result.facilities.length > 0);
}
extractAdditionalInfo(properties) {
const additionalInfo = {};
// Extract useful properties while filtering out internal data
const relevantKeys = ['CASE_SIZE', 'HOMES', 'PUBLIC_PLACES', 'SYMBOLCOLOR', 'Type'];
for (const key of relevantKeys) {
if (properties[key] && properties[key] !== '') {
additionalInfo[key.toLowerCase()] = properties[key];
}
}
return additionalInfo;
}
generateSummary(results) {
const totalFacilities = results.reduce((sum, result) => sum + result.facilities.length, 0);
const categoriesFound = [...new Set(results.map(result => result.category))];
let nearestFacility = undefined;
let minDistance = Infinity;
for (const result of results) {
for (const facility of result.facilities) {
if (facility.distance < minDistance) {
minDistance = facility.distance;
nearestFacility = {
name: facility.name,
distance: facility.distance,
category: facility.category,
};
}
}
}
return {
totalFacilities,
categoriesFound,
nearestFacility,
};
}
createErrorResponse(message, startTime, apiCalls, cacheHits) {
return {
success: false,
location: {
name: 'Unknown',
coordinates: [0, 0],
},
searchRadius: 0,
results: [],
summary: {
totalFacilities: 0,
categoriesFound: [],
},
metadata: {
searchTime: Date.now() - startTime,
apiCalls,
cacheHits,
},
};
}
}
exports.LandmarksDiscoveryTool = LandmarksDiscoveryTool;