Skip to main content
Glama
samwang0723

Restaurant Booking MCP Server

googleMapsService.ts17.1 kB
import { PlacesClient } from '@googlemaps/places'; import { Location, Restaurant, RestaurantSearchParams, } from '../types/index.js'; export class GoogleMapsService { private client: PlacesClient; constructor(apiKey: string) { this.client = new PlacesClient({ apiKey: apiKey, }); } /** * Calculate the distance between two points using the Haversine formula * @param lat1 Latitude of first point * @param lon1 Longitude of first point * @param lat2 Latitude of second point * @param lon2 Longitude of second point * @returns Distance in meters */ private calculateDistance( lat1: number, lon1: number, lat2: number, lon2: number ): number { const R = 6371000; // Earth's radius in meters const dLat = this.toRadians(lat2 - lat1); const dLon = this.toRadians(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // Distance in meters } /** * Convert degrees to radians */ private toRadians(degrees: number): number { return degrees * (Math.PI / 180); } /** * Search for restaurants using Text Search API * Supports direct text queries like "good restaurant in Paris" or "sushi near Tokyo" */ async searchRestaurants( params: RestaurantSearchParams ): Promise<Restaurant[]> { try { const { location, placeName, cuisineTypes, keyword, radius = 2000, locale = 'en', } = params; // Build search query for Text Search API let textQuery = ''; // If keyword is provided, prioritize it if (keyword) { textQuery = keyword; // Add location context if (placeName) { textQuery += ` in ${placeName}`; } // If cuisine types are also provided, combine them if (cuisineTypes && cuisineTypes.length > 0) { textQuery += ` ${cuisineTypes.join(' OR ')}`; } } else { // Build query from cuisine types and location const cuisineQuery = cuisineTypes && cuisineTypes.length > 0 ? cuisineTypes.join(' OR ') + ' restaurant' : 'good restaurant'; if (placeName) { textQuery = `${cuisineQuery} in ${placeName}`; } else { textQuery = cuisineQuery; } } // Prepare field mask based on required fields const fieldMask = this.getSearchFieldMask(); // Build request for Text Search API let request; let response; // Use searchNearby only when we have coordinates AND no specific cuisine is requested if ( location && (!cuisineTypes || cuisineTypes.length === 0) && !placeName ) { request = { includedTypes: ['restaurant'], locationRestriction: { circle: { center: { latitude: location.latitude, longitude: location.longitude, }, radius: radius, }, }, maxResultCount: 20, languageCode: locale, }; console.info('request', request); response = await this.client.searchNearby(request, { otherArgs: { headers: { 'X-Goog-FieldMask': fieldMask, }, }, }); } else { // Use searchText for: // 1. Place name searches (with or without cuisine) // 2. Coordinate searches with specific cuisine // 3. Keyword searches if (location && !placeName) { // Coordinate-based search with cuisine - add location bias request = { textQuery, locationBias: { circle: { center: { latitude: location.latitude, longitude: location.longitude, }, radius: radius, }, }, maxResultCount: 20, languageCode: locale, }; } else { // Place name search or no location provided request = { textQuery, maxResultCount: 20, languageCode: locale, }; } console.info('request', request); response = await this.client.searchText(request, { otherArgs: { headers: { 'X-Goog-FieldMask': fieldMask, }, }, }); } if (!response?.[0]?.places) { return []; } const places = response[0].places; // Convert API response to Restaurant objects const restaurants: Restaurant[] = []; for (const place of places) { try { const restaurant = this.convertPlaceToRestaurant(place, location); if (restaurant) { restaurants.push(restaurant); } } catch (error) { console.error('Error converting place to restaurant:', error); } } // Sort restaurants by distance if location provided if (location) { restaurants.sort((a, b) => { const distanceA = a.distance || 0; const distanceB = b.distance || 0; return distanceA - distanceB; }); } return restaurants; } catch (error) { console.error('Error searching restaurants:', error); throw new Error( `Failed to search restaurants: ${ error instanceof Error ? error.message : 'Unknown error' }` ); } } /** * Get field mask for search requests based on SKU tiers */ private getSearchFieldMask(): string { // Using Pro SKU fields for comprehensive restaurant data const proFields = [ 'places.id', 'places.displayName', 'places.formattedAddress', 'places.location', 'places.types', 'places.primaryType', 'places.businessStatus', 'places.googleMapsUri', 'places.photos', ]; // Adding Enterprise SKU fields for restaurant-specific data const enterpriseFields = [ 'places.rating', 'places.userRatingCount', 'places.priceLevel', 'places.currentOpeningHours', 'places.internationalPhoneNumber', 'places.websiteUri', ]; // Adding Enterprise + Atmosphere SKU fields for booking and service info const atmosphereFields = [ 'places.reservable', 'places.curbsidePickup', 'places.delivery', 'places.dineIn', 'places.takeout', 'places.servesBreakfast', 'places.servesLunch', 'places.servesDinner', 'places.servesBrunch', 'places.servesBeer', 'places.servesWine', 'places.servesVegetarianFood', 'places.reviews', ]; return [...proFields, ...enterpriseFields, ...atmosphereFields].join(','); } /** * Convert API place response to Restaurant object */ private convertPlaceToRestaurant( place: any, searchLocation?: Location ): Restaurant | null { try { // Validate required fields if ( !place.id || !place.displayName || !place.formattedAddress || !place.location ) { return null; } // Calculate distance if search location provided let distance: number | undefined; if (searchLocation && place.location) { distance = Math.round( this.calculateDistance( searchLocation.latitude, searchLocation.longitude, place.location.latitude, place.location.longitude ) ); } // Analyze booking information from website const bookingInfo = this.analyzeBookingInfo( place.websiteUri, place.internationalPhoneNumber ); // Generate Google Maps URL const googleMapsUrl = place.googleMapsUri || this.generateGoogleMapsUrl(place.id); // Convert reviews if present const reviews = place.reviews?.map((review: any) => ({ authorName: review.authorAttribution?.displayName || 'Anonymous', rating: review.rating || 0, text: review.text?.text || '', time: review.publishTime ? Date.parse(review.publishTime) : 0, })) || []; return { placeId: place.id, name: place.displayName?.text || place.displayName, address: place.formattedAddress, location: { latitude: place.location.latitude, longitude: place.location.longitude, }, rating: place.rating || 0, userRatingsTotal: place.userRatingCount || 0, priceLevel: this.convertPriceLevelFromEnum(place.priceLevel), cuisineTypes: this.extractCuisineTypes(place.types || []), phoneNumber: place.internationalPhoneNumber, website: place.websiteUri, googleMapsUrl, distance, bookingInfo, reservable: place.reservable || false, curbsidePickup: place.curbsidePickup || false, delivery: place.delivery || false, dineIn: place.dineIn || false, takeout: place.takeout || false, servesBreakfast: place.servesBreakfast || false, servesLunch: place.servesLunch || false, servesDinner: place.servesDinner || false, servesBrunch: place.servesBrunch || false, servesBeer: place.servesBeer || false, servesWine: place.servesWine || false, servesVegetarianFood: place.servesVegetarianFood || false, openingHours: place.currentOpeningHours ? { openNow: place.currentOpeningHours.openNow || false, weekdayText: place.currentOpeningHours.weekdayDescriptions, } : undefined, reviews: reviews.slice(0, 5), // Limit to 5 reviews }; } catch (error) { console.error('Error converting place to restaurant:', error); return null; } } /** * Convert price level enum back to number */ private convertPriceLevelFromEnum(priceLevel?: string): number | undefined { if (!priceLevel) return undefined; const priceLevels: { [key: string]: number } = { PRICE_LEVEL_INEXPENSIVE: 1, PRICE_LEVEL_MODERATE: 2, PRICE_LEVEL_EXPENSIVE: 3, PRICE_LEVEL_VERY_EXPENSIVE: 4, }; return priceLevels[priceLevel]; } /** * Analyze booking information from website URL and phone number */ private analyzeBookingInfo( website?: string, phoneNumber?: string ): { reservable: boolean; bookingUrl?: string; bookingPlatform?: | 'opentable' | 'resy' | 'yelp' | 'restaurant_website' | 'google_reserve' | 'other'; supportsOnlineBooking: boolean; requiresPhone: boolean; } { const bookingInfo: { reservable: boolean; bookingUrl?: string; bookingPlatform?: | 'opentable' | 'resy' | 'yelp' | 'restaurant_website' | 'google_reserve' | 'other'; supportsOnlineBooking: boolean; requiresPhone: boolean; } = { reservable: false, supportsOnlineBooking: false, requiresPhone: false, }; // Check if phone number is available for reservations if (phoneNumber) { bookingInfo.reservable = true; bookingInfo.requiresPhone = true; } // Analyze website for booking platforms if (website) { const url = website.toLowerCase(); let hostname = ''; try { hostname = new URL(website).hostname.toLowerCase(); } catch (_e) { // If website is not a valid URL, fallback to substring checks (optional) hostname = ''; } // OpenTable detection if (hostname === 'opentable.com' || hostname.endsWith('.opentable.com')) { return { ...bookingInfo, reservable: true, bookingUrl: website, bookingPlatform: 'opentable', supportsOnlineBooking: true, requiresPhone: false, }; } // Resy detection if (hostname === 'resy.com' || hostname.endsWith('.resy.com')) { return { ...bookingInfo, reservable: true, bookingUrl: website, bookingPlatform: 'resy', supportsOnlineBooking: true, requiresPhone: false, }; } // Yelp reservations detection if ( (hostname === 'yelp.com' || hostname.endsWith('.yelp.com')) && (url.includes('reservations') || url.includes('book')) ) { return { ...bookingInfo, reservable: true, bookingUrl: website, bookingPlatform: 'yelp', supportsOnlineBooking: true, requiresPhone: false, }; } // Google Reserve detection if ( hostname === 'reserve.google.com' || (hostname === 'google.com' && url.includes('/reserve')) ) { return { ...bookingInfo, reservable: true, bookingUrl: website, bookingPlatform: 'google_reserve', supportsOnlineBooking: true, requiresPhone: false, }; } // Generic restaurant website with potential booking if (this.hasBookingKeywords(url)) { return { ...bookingInfo, reservable: true, bookingUrl: website, bookingPlatform: 'restaurant_website', supportsOnlineBooking: true, requiresPhone: false, }; } // Website exists but no clear booking platform detected bookingInfo.reservable = true; bookingInfo.bookingUrl = website; bookingInfo.bookingPlatform = 'other'; } return bookingInfo; } /** * Check if URL contains booking-related keywords */ private hasBookingKeywords(url: string): boolean { const bookingKeywords = [ 'reservation', 'reservations', 'book', 'booking', 'table', 'reserve', 'dine', 'dining', 'order', 'menu', ]; return bookingKeywords.some(keyword => url.includes(keyword)); } /** * Generate Google Maps URL for a restaurant */ private generateGoogleMapsUrl(placeId: string): string { // Use place_id for the most accurate Google Maps URL // This format directly opens the place in Google Maps return `https://www.google.com/maps/search/?api=1&query=Google&query_place_id=${placeId}`; } /** * Extract cuisine types from Google Places types array */ private extractCuisineTypes(types: string[]): string[] { const cuisineMap: { [key: string]: string } = { chinese_restaurant: 'Chinese', japanese_restaurant: 'Japanese', korean_restaurant: 'Korean', thai_restaurant: 'Thai', vietnamese_restaurant: 'Vietnamese', indian_restaurant: 'Indian', italian_restaurant: 'Italian', french_restaurant: 'French', mexican_restaurant: 'Mexican', american_restaurant: 'American', mediterranean_restaurant: 'Mediterranean', greek_restaurant: 'Greek', turkish_restaurant: 'Turkish', spanish_restaurant: 'Spanish', german_restaurant: 'German', brazilian_restaurant: 'Brazilian', seafood_restaurant: 'Seafood', steakhouse: 'Steakhouse', pizza_restaurant: 'Pizza', bakery: 'Bakery', cafe: 'Cafe', fast_food_restaurant: 'Fast Food', fine_dining_restaurant: 'Fine Dining', buffet_restaurant: 'Buffet', barbecue_restaurant: 'BBQ', sushi_restaurant: 'Sushi', vegetarian_restaurant: 'Vegetarian', vegan_restaurant: 'Vegan', }; const cuisines: string[] = []; for (const type of types) { // Handle both old format and new format types const typeKey = typeof type === 'string' ? type : String(type).toLowerCase(); if (cuisineMap[typeKey]) { cuisines.push(cuisineMap[typeKey]); } } // If no specific cuisine found, add generic restaurant type if ( cuisines.length === 0 && (types.includes('restaurant') || types.includes('establishment')) ) { cuisines.push('Restaurant'); } return cuisines; } /** * Search for restaurants with specific cuisine types */ async searchByCuisine( locationOrPlaceName: Location | string, cuisineType: string, radius: number = 7000, locale: string = 'en' ): Promise<Restaurant[]> { const searchParams: RestaurantSearchParams = { ...(typeof locationOrPlaceName === 'string' ? { placeName: locationOrPlaceName } : { location: locationOrPlaceName }), cuisineTypes: [cuisineType], mood: '', event: 'casual dining', radius, locale, }; return this.searchRestaurants(searchParams); } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/samwang0723/mcp-booking'

If you have feedback or need assistance with the MCP directory API, please join our Discord server