UNPKG

@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
"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;