diff --git a/README.md b/README.md index 5e13733..5e5cc55 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup | Tool | Description | |------|-------------| -| `search_nearby` | Find places near a location by type (restaurant, cafe, hotel, etc.). Supports filtering by radius, rating, and open status. | +| `maps_search_nearby` | Find places near a location by type (restaurant, cafe, hotel, etc.). Supports filtering by radius, rating, and open status. | | `maps_search_places` | Free-text place search (e.g., "sushi restaurants in Tokyo"). Supports location bias, rating, open-now filters. | -| `get_place_details` | Get full details for a place by its place_id — reviews, phone, website, hours, photos. | +| `maps_place_details` | Get full details for a place by its place_id — reviews, phone, website, hours, photos. | | `maps_geocode` | Convert an address or landmark name into GPS coordinates. | | `maps_reverse_geocode` | Convert GPS coordinates into a street address. | | `maps_distance_matrix` | Calculate travel distances and times between multiple origins and destinations. | @@ -200,9 +200,9 @@ src/ │ └── toolclass.ts # Legacy Google Maps API client ├── tools/ │ └── maps/ -│ ├── searchNearby.ts # search_nearby tool +│ ├── searchNearby.ts # maps_search_nearby tool │ ├── searchPlaces.ts # maps_search_places tool -│ ├── placeDetails.ts # get_place_details tool +│ ├── placeDetails.ts # maps_place_details tool │ ├── geocode.ts # maps_geocode tool │ ├── reverseGeocode.ts # maps_reverse_geocode tool │ ├── distanceMatrix.ts # maps_distance_matrix tool diff --git a/src/cli.ts b/src/cli.ts index 642cee0..20265cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -86,6 +86,9 @@ const EXEC_TOOLS = [ "elevation", "timezone", "weather", + "explore-area", + "plan-route", + "compare-places", ] as const; async function execTool(toolName: string, params: any, apiKey: string): Promise { @@ -102,6 +105,7 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise< case "search-nearby": case "search_nearby": + case "maps_search_nearby": return searcher.searchNearby(params); case "search-places": @@ -116,6 +120,7 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise< case "place-details": case "get_place_details": + case "maps_place_details": return searcher.getPlaceDetails(params.placeId); case "directions": @@ -150,6 +155,18 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise< params.forecastHours ); + case "explore-area": + case "maps_explore_area": + return searcher.exploreArea(params); + + case "plan-route": + case "maps_plan_route": + return searcher.planRoute(params); + + case "compare-places": + case "maps_compare_places": + return searcher.comparePlaces(params); + default: throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`); } diff --git a/src/config.ts b/src/config.ts index 2a1ec3a..2d64375 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,9 @@ import { Elevation, ElevationParams } from "./tools/maps/elevation.js"; import { SearchPlaces, SearchPlacesParams } from "./tools/maps/searchPlaces.js"; import { Timezone, TimezoneParams } from "./tools/maps/timezone.js"; import { Weather, WeatherParams } from "./tools/maps/weather.js"; +import { ExploreArea, ExploreAreaParams } from "./tools/maps/exploreArea.js"; +import { PlanRoute, PlanRouteParams } from "./tools/maps/planRoute.js"; +import { ComparePlaces, ComparePlacesParams } from "./tools/maps/comparePlaces.js"; // All Google Maps tools are read-only API queries const MAPS_TOOL_ANNOTATIONS = { @@ -101,6 +104,27 @@ const serverConfigs: ServerInstanceConfig[] = [ annotations: MAPS_TOOL_ANNOTATIONS, action: (params: WeatherParams) => Weather.ACTION(params), }, + { + name: ExploreArea.NAME, + description: ExploreArea.DESCRIPTION, + schema: ExploreArea.SCHEMA, + annotations: MAPS_TOOL_ANNOTATIONS, + action: (params: ExploreAreaParams) => ExploreArea.ACTION(params), + }, + { + name: PlanRoute.NAME, + description: PlanRoute.DESCRIPTION, + schema: PlanRoute.SCHEMA, + annotations: MAPS_TOOL_ANNOTATIONS, + action: (params: PlanRouteParams) => PlanRoute.ACTION(params), + }, + { + name: ComparePlaces.NAME, + description: ComparePlaces.DESCRIPTION, + schema: ComparePlaces.SCHEMA, + annotations: MAPS_TOOL_ANNOTATIONS, + action: (params: ComparePlacesParams) => ComparePlaces.ACTION(params), + }, ], }, ]; diff --git a/src/services/PlacesSearcher.ts b/src/services/PlacesSearcher.ts index 58ea8b1..8d31d93 100644 --- a/src/services/PlacesSearcher.ts +++ b/src/services/PlacesSearcher.ts @@ -316,6 +316,193 @@ export class PlacesSearcher { } } + // --------------- Composite Tools --------------- + + async exploreArea(params: { location: string; types?: string[]; radius?: number; topN?: number }): Promise { + const types = params.types || ["restaurant", "cafe", "attraction"]; + const radius = params.radius || 1000; + const topN = params.topN || 3; + + // 1. Geocode + const geo = await this.geocode(params.location); + if (!geo.success || !geo.data) throw new Error(geo.error || "Geocode failed"); + const { lat, lng } = geo.data.location; + + // 2. Search each type + const categories: any[] = []; + for (const type of types) { + const search = await this.searchNearby({ + center: { value: `${lat},${lng}`, isCoordinates: true }, + keyword: type, + radius, + }); + if (!search.success || !search.data) continue; + + // 3. Get details for top N + const topPlaces = search.data.slice(0, topN); + const detailed = []; + for (const place of topPlaces) { + if (!place.place_id) continue; + const details = await this.getPlaceDetails(place.place_id); + detailed.push({ + name: place.name, + address: place.address, + rating: place.rating, + total_ratings: place.total_ratings, + open_now: place.open_now, + phone: details.data?.phone, + website: details.data?.website, + }); + } + categories.push({ type, count: search.data.length, top: detailed }); + } + + return { + success: true, + data: { + location: { address: geo.data.formatted_address, lat, lng }, + radius, + categories, + }, + }; + } + + async planRoute(params: { + stops: string[]; + mode?: "driving" | "walking" | "bicycling" | "transit"; + optimize?: boolean; + }): Promise { + const mode = params.mode || "driving"; + const stops = params.stops; + if (stops.length < 2) throw new Error("Need at least 2 stops"); + + // 1. Geocode all stops + const geocoded: Array<{ originalName: string; address: string; lat: number; lng: number }> = []; + for (const stop of stops) { + const geo = await this.geocode(stop); + if (!geo.success || !geo.data) throw new Error(`Failed to geocode: ${stop}`); + geocoded.push({ + originalName: stop, + address: geo.data.formatted_address, + lat: geo.data.location.lat, + lng: geo.data.location.lng, + }); + } + + // 2. If optimize requested and > 2 stops, use distance-matrix + nearest-neighbor + // Use driving mode for optimization (transit matrix requires departure_time and often returns null) + let orderedStops = geocoded; + if (params.optimize !== false && geocoded.length > 2) { + const matrix = await this.calculateDistanceMatrix(stops, stops, "driving"); + if (matrix.success && matrix.data) { + // Nearest-neighbor from first stop + const visited = new Set([0]); + const order = [0]; + let current = 0; + while (visited.size < geocoded.length) { + let bestIdx = -1; + let bestDuration = Infinity; + for (let i = 0; i < geocoded.length; i++) { + if (visited.has(i)) continue; + const dur = matrix.data.durations[current]?.[i]?.value ?? Infinity; + if (dur < bestDuration) { + bestDuration = dur; + bestIdx = i; + } + } + if (bestIdx === -1) break; + visited.add(bestIdx); + order.push(bestIdx); + current = bestIdx; + } + orderedStops = order.map((i) => geocoded[i]); + } + } + + // 3. Get directions between consecutive stops (use original names for reliable results) + const legs: any[] = []; + let totalDistance = 0; + let totalDuration = 0; + for (let i = 0; i < orderedStops.length - 1; i++) { + const dir = await this.getDirections(orderedStops[i].originalName, orderedStops[i + 1].originalName, mode); + if (dir.success && dir.data) { + totalDistance += dir.data.total_distance.value; + totalDuration += dir.data.total_duration.value; + legs.push({ + from: orderedStops[i].originalName, + to: orderedStops[i + 1].originalName, + distance: dir.data.total_distance.text, + duration: dir.data.total_duration.text, + }); + } else { + legs.push({ + from: orderedStops[i].originalName, + to: orderedStops[i + 1].originalName, + distance: "unknown", + duration: "unknown", + note: dir.error || "Directions unavailable for this segment", + }); + } + } + + return { + success: true, + data: { + mode, + optimized: params.optimize !== false && geocoded.length > 2, + stops: orderedStops.map((s) => `${s.originalName} (${s.address})`), + legs, + total_distance: `${(totalDistance / 1000).toFixed(1)} km`, + total_duration: `${Math.round(totalDuration / 60)} min`, + }, + }; + } + + async comparePlaces(params: { + query: string; + userLocation?: { latitude: number; longitude: number }; + limit?: number; + }): Promise { + const limit = params.limit || 5; + + // 1. Search + const search = await this.searchText({ query: params.query }); + if (!search.success || !search.data) throw new Error(search.error || "Search failed"); + + const places = search.data.slice(0, limit); + + // 2. Get details for each + const compared: any[] = []; + for (const place of places) { + const details = await this.getPlaceDetails(place.place_id); + compared.push({ + name: place.name, + address: place.address, + rating: place.rating, + total_ratings: place.total_ratings, + open_now: place.open_now, + phone: details.data?.phone, + website: details.data?.website, + price_level: details.data?.price_level, + }); + } + + // 3. Distance from user location (if provided) + if (params.userLocation && compared.length > 0) { + const origin = `${params.userLocation.latitude},${params.userLocation.longitude}`; + const destinations = places.map((p: any) => `${p.location.lat},${p.location.lng}`); + const matrix = await this.calculateDistanceMatrix([origin], destinations, "driving"); + if (matrix.success && matrix.data) { + for (let i = 0; i < compared.length; i++) { + compared[i].distance = matrix.data.distances[0]?.[i]?.text; + compared[i].drive_time = matrix.data.durations[0]?.[i]?.text; + } + } + } + + return { success: true, data: compared }; + } + async getElevation(locations: Array<{ latitude: number; longitude: number }>): Promise { try { const result = await this.mapsTools.getElevation(locations); diff --git a/src/tools/maps/comparePlaces.ts b/src/tools/maps/comparePlaces.ts new file mode 100644 index 0000000..1b3f50b --- /dev/null +++ b/src/tools/maps/comparePlaces.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { PlacesSearcher } from "../../services/PlacesSearcher.js"; +import { getCurrentApiKey } from "../../utils/requestContext.js"; + +const NAME = "maps_compare_places"; +const DESCRIPTION = + "Compare multiple places side-by-side in one call — searches by query, gets details for each result, and optionally calculates distance from your location. Use when the user asks 'which restaurant should I pick', 'compare these hotels', or needs a decision table. Replaces the manual chain of search-places → place-details → distance-matrix."; + +const SCHEMA = { + query: z.string().describe("Search query (e.g., 'ramen near Shibuya', 'hotels in Taipei')"), + userLocation: z + .object({ + latitude: z.number().describe("Your latitude"), + longitude: z.number().describe("Your longitude"), + }) + .optional() + .describe("Your current location — if provided, adds distance and drive time to each result"), + limit: z.number().optional().describe("Max places to compare (default: 5)"), +}; + +export type ComparePlacesParams = z.infer>; + +async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> { + try { + const apiKey = getCurrentApiKey(); + const searcher = new PlacesSearcher(apiKey); + const result = await searcher.comparePlaces(params); + + return { + content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }], + isError: false, + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error comparing places: ${error.message}` }], + }; + } +} + +export const ComparePlaces = { NAME, DESCRIPTION, SCHEMA, ACTION }; diff --git a/src/tools/maps/exploreArea.ts b/src/tools/maps/exploreArea.ts new file mode 100644 index 0000000..cf9911c --- /dev/null +++ b/src/tools/maps/exploreArea.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { PlacesSearcher } from "../../services/PlacesSearcher.js"; +import { getCurrentApiKey } from "../../utils/requestContext.js"; + +const NAME = "maps_explore_area"; +const DESCRIPTION = + "Explore what's around a location in one call — searches multiple place types, gets details for the top results, and returns a categorized summary. Use when the user asks 'what's around here', 'explore the area near my hotel', or needs a quick overview of a neighborhood. Replaces the manual chain of geocode → search-nearby → place-details."; + +const SCHEMA = { + location: z.string().describe("Address or landmark to explore around"), + types: z + .array(z.string()) + .optional() + .describe("Place types to search (default: restaurant, cafe, attraction). Examples: hotel, bar, park, museum"), + radius: z.number().optional().describe("Search radius in meters (default: 1000)"), + topN: z.number().optional().describe("Number of top results per type to get details for (default: 3)"), +}; + +export type ExploreAreaParams = z.infer>; + +async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> { + try { + const apiKey = getCurrentApiKey(); + const searcher = new PlacesSearcher(apiKey); + const result = await searcher.exploreArea(params); + + return { + content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }], + isError: false, + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error exploring area: ${error.message}` }], + }; + } +} + +export const ExploreArea = { NAME, DESCRIPTION, SCHEMA, ACTION }; diff --git a/src/tools/maps/placeDetails.ts b/src/tools/maps/placeDetails.ts index 467ef4f..639b5cb 100644 --- a/src/tools/maps/placeDetails.ts +++ b/src/tools/maps/placeDetails.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { PlacesSearcher } from "../../services/PlacesSearcher.js"; import { getCurrentApiKey } from "../../utils/requestContext.js"; -const NAME = "get_place_details"; +const NAME = "maps_place_details"; const DESCRIPTION = "Get comprehensive details for a specific place using its Google Maps place_id. Use after search_nearby or maps_search_places to get full information including reviews, phone number, website, opening hours, and photos. Returns everything needed to evaluate or contact a business."; diff --git a/src/tools/maps/planRoute.ts b/src/tools/maps/planRoute.ts new file mode 100644 index 0000000..e427160 --- /dev/null +++ b/src/tools/maps/planRoute.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { PlacesSearcher } from "../../services/PlacesSearcher.js"; +import { getCurrentApiKey } from "../../utils/requestContext.js"; + +const NAME = "maps_plan_route"; +const DESCRIPTION = + "Plan an optimized multi-stop route in one call — geocodes all stops, finds the most efficient visit order using distance-matrix, and returns step-by-step directions between each stop. Use when the user says 'visit these 5 places efficiently', 'plan a route through A, B, C', or needs a multi-stop itinerary. Replaces the manual chain of geocode → distance-matrix → directions."; + +const SCHEMA = { + stops: z.array(z.string()).min(2).describe("List of addresses or landmarks to visit (minimum 2)"), + mode: z.enum(["driving", "walking", "bicycling", "transit"]).optional().describe("Travel mode (default: driving)"), + optimize: z + .boolean() + .optional() + .describe("Auto-optimize visit order by nearest-neighbor (default: true). Set false to keep original order."), +}; + +export type PlanRouteParams = z.infer>; + +async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> { + try { + const apiKey = getCurrentApiKey(); + const searcher = new PlacesSearcher(apiKey); + const result = await searcher.planRoute(params); + + return { + content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }], + isError: false, + }; + } catch (error: any) { + return { + isError: true, + content: [{ type: "text", text: `Error planning route: ${error.message}` }], + }; + } +} + +export const PlanRoute = { NAME, DESCRIPTION, SCHEMA, ACTION }; diff --git a/src/tools/maps/searchNearby.ts b/src/tools/maps/searchNearby.ts index b1cba88..aaf667f 100644 --- a/src/tools/maps/searchNearby.ts +++ b/src/tools/maps/searchNearby.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { PlacesSearcher } from "../../services/PlacesSearcher.js"; import { getCurrentApiKey } from "../../utils/requestContext.js"; -const NAME = "search_nearby"; +const NAME = "maps_search_nearby"; const DESCRIPTION = "Find places near a specific location by type (e.g., restaurants, cafes, hotels). Use when the user wants to discover what's around a given address or coordinates, such as 'find coffee shops near Times Square' or 'what hotels are near the airport'. Supports filtering by place type, search radius, minimum rating, and whether currently open."; diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 6ec1784..e40d642 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -200,8 +200,8 @@ async function testListTools(session: McpSession): Promise { const toolNames = tools.map((t: any) => t.name); const expectedTools = [ - "search_nearby", - "get_place_details", + "maps_search_nearby", + "maps_place_details", "maps_geocode", "maps_reverse_geocode", "maps_distance_matrix", @@ -313,7 +313,7 @@ async function testToolCalls(session: McpSession): Promise { // Test search_nearby (uses Places API New) const nearbyResult = await sendRequest(session, "tools/call", { - name: "search_nearby", + name: "maps_search_nearby", arguments: { center: { value: "35.6586,139.7454", isCoordinates: true }, keyword: "restaurant", @@ -634,7 +634,7 @@ async function testExecMode(): Promise { try { return execFileSync("node", [cliPath, "exec", tool, params, "--apikey", API_KEY], { encoding: "utf-8", - timeout: 15000, + timeout: 30000, }).trim(); } catch (err: any) { return err.stdout?.trim() ?? err.message;