Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@
"elevation",
"timezone",
"weather",
"explore-area",
"plan-route",
"compare-places",
] as const;

async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {

Check warning on line 94 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

Check warning on line 94 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const searcher = new PlacesSearcher(apiKey);

switch (toolName) {
Expand All @@ -102,6 +105,7 @@

case "search-nearby":
case "search_nearby":
case "maps_search_nearby":
return searcher.searchNearby(params);

case "search-places":
Expand All @@ -116,6 +120,7 @@

case "place-details":
case "get_place_details":
case "maps_place_details":
return searcher.getPlaceDetails(params.placeId);

case "directions":
Expand Down Expand Up @@ -150,6 +155,18 @@
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(", ")}`);
}
Expand All @@ -173,7 +190,7 @@
const packageJsonPath = resolve(__dirname, "../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
packageVersion = packageJson.version;
} catch (e) {

Check warning on line 193 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

'e' is defined but never used
packageVersion = "0.0.0";
}

Expand Down Expand Up @@ -224,7 +241,7 @@
const result = await execTool(argv.tool as string, params, argv.apikey as string);
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (error: any) {

Check warning on line 244 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
console.error(JSON.stringify({ error: error.message }, null, 2));
process.exit(1);
}
Expand Down
24 changes: 24 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
},
],
},
];
Expand Down
187 changes: 187 additions & 0 deletions src/services/PlacesSearcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,193 @@ export class PlacesSearcher {
}
}

// --------------- Composite Tools ---------------

async exploreArea(params: { location: string; types?: string[]; radius?: number; topN?: number }): Promise<any> {
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<any> {
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<number>([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<any> {
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<ElevationResponse> {
try {
const result = await this.mapsTools.getElevation(locations);
Expand Down
41 changes: 41 additions & 0 deletions src/tools/maps/comparePlaces.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodObject<typeof SCHEMA>>;

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 };
39 changes: 39 additions & 0 deletions src/tools/maps/exploreArea.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodObject<typeof SCHEMA>>;

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 };
2 changes: 1 addition & 1 deletion src/tools/maps/placeDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";

Expand Down
Loading
Loading