Skip to content

Commit de643e2

Browse files
cablateclaude
andcommitted
feat: add composite tools — explore-area, plan-route, compare-places
Three high-level tools that chain atomic tools internally: - maps_explore_area: geocode → search-nearby (multi-type) → place-details "What's around Tokyo Tower?" → categorized results with details in 1 call - maps_plan_route: geocode → distance-matrix → nearest-neighbor → directions "Visit these 5 places efficiently" → optimized route with legs in 1 call - maps_compare_places: search-places → place-details → distance-matrix "Which ramen shop near Shibuya?" → comparison table in 1 call Uses driving mode for route optimization (transit matrix returns nulls). Handles directions failures gracefully (marks leg as "unknown"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b87fd09 commit de643e2

6 files changed

Lines changed: 344 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ const EXEC_TOOLS = [
8686
"elevation",
8787
"timezone",
8888
"weather",
89+
"explore-area",
90+
"plan-route",
91+
"compare-places",
8992
] as const;
9093

9194
async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {
@@ -152,6 +155,18 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise<
152155
params.forecastHours
153156
);
154157

158+
case "explore-area":
159+
case "maps_explore_area":
160+
return searcher.exploreArea(params);
161+
162+
case "plan-route":
163+
case "maps_plan_route":
164+
return searcher.planRoute(params);
165+
166+
case "compare-places":
167+
case "maps_compare_places":
168+
return searcher.comparePlaces(params);
169+
155170
default:
156171
throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`);
157172
}

src/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { Elevation, ElevationParams } from "./tools/maps/elevation.js";
1111
import { SearchPlaces, SearchPlacesParams } from "./tools/maps/searchPlaces.js";
1212
import { Timezone, TimezoneParams } from "./tools/maps/timezone.js";
1313
import { Weather, WeatherParams } from "./tools/maps/weather.js";
14+
import { ExploreArea, ExploreAreaParams } from "./tools/maps/exploreArea.js";
15+
import { PlanRoute, PlanRouteParams } from "./tools/maps/planRoute.js";
16+
import { ComparePlaces, ComparePlacesParams } from "./tools/maps/comparePlaces.js";
1417

1518
// All Google Maps tools are read-only API queries
1619
const MAPS_TOOL_ANNOTATIONS = {
@@ -101,6 +104,27 @@ const serverConfigs: ServerInstanceConfig[] = [
101104
annotations: MAPS_TOOL_ANNOTATIONS,
102105
action: (params: WeatherParams) => Weather.ACTION(params),
103106
},
107+
{
108+
name: ExploreArea.NAME,
109+
description: ExploreArea.DESCRIPTION,
110+
schema: ExploreArea.SCHEMA,
111+
annotations: MAPS_TOOL_ANNOTATIONS,
112+
action: (params: ExploreAreaParams) => ExploreArea.ACTION(params),
113+
},
114+
{
115+
name: PlanRoute.NAME,
116+
description: PlanRoute.DESCRIPTION,
117+
schema: PlanRoute.SCHEMA,
118+
annotations: MAPS_TOOL_ANNOTATIONS,
119+
action: (params: PlanRouteParams) => PlanRoute.ACTION(params),
120+
},
121+
{
122+
name: ComparePlaces.NAME,
123+
description: ComparePlaces.DESCRIPTION,
124+
schema: ComparePlaces.SCHEMA,
125+
annotations: MAPS_TOOL_ANNOTATIONS,
126+
action: (params: ComparePlacesParams) => ComparePlaces.ACTION(params),
127+
},
104128
],
105129
},
106130
];

src/services/PlacesSearcher.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,193 @@ export class PlacesSearcher {
316316
}
317317
}
318318

319+
// --------------- Composite Tools ---------------
320+
321+
async exploreArea(params: { location: string; types?: string[]; radius?: number; topN?: number }): Promise<any> {
322+
const types = params.types || ["restaurant", "cafe", "attraction"];
323+
const radius = params.radius || 1000;
324+
const topN = params.topN || 3;
325+
326+
// 1. Geocode
327+
const geo = await this.geocode(params.location);
328+
if (!geo.success || !geo.data) throw new Error(geo.error || "Geocode failed");
329+
const { lat, lng } = geo.data.location;
330+
331+
// 2. Search each type
332+
const categories: any[] = [];
333+
for (const type of types) {
334+
const search = await this.searchNearby({
335+
center: { value: `${lat},${lng}`, isCoordinates: true },
336+
keyword: type,
337+
radius,
338+
});
339+
if (!search.success || !search.data) continue;
340+
341+
// 3. Get details for top N
342+
const topPlaces = search.data.slice(0, topN);
343+
const detailed = [];
344+
for (const place of topPlaces) {
345+
if (!place.place_id) continue;
346+
const details = await this.getPlaceDetails(place.place_id);
347+
detailed.push({
348+
name: place.name,
349+
address: place.address,
350+
rating: place.rating,
351+
total_ratings: place.total_ratings,
352+
open_now: place.open_now,
353+
phone: details.data?.phone,
354+
website: details.data?.website,
355+
});
356+
}
357+
categories.push({ type, count: search.data.length, top: detailed });
358+
}
359+
360+
return {
361+
success: true,
362+
data: {
363+
location: { address: geo.data.formatted_address, lat, lng },
364+
radius,
365+
categories,
366+
},
367+
};
368+
}
369+
370+
async planRoute(params: {
371+
stops: string[];
372+
mode?: "driving" | "walking" | "bicycling" | "transit";
373+
optimize?: boolean;
374+
}): Promise<any> {
375+
const mode = params.mode || "driving";
376+
const stops = params.stops;
377+
if (stops.length < 2) throw new Error("Need at least 2 stops");
378+
379+
// 1. Geocode all stops
380+
const geocoded: Array<{ originalName: string; address: string; lat: number; lng: number }> = [];
381+
for (const stop of stops) {
382+
const geo = await this.geocode(stop);
383+
if (!geo.success || !geo.data) throw new Error(`Failed to geocode: ${stop}`);
384+
geocoded.push({
385+
originalName: stop,
386+
address: geo.data.formatted_address,
387+
lat: geo.data.location.lat,
388+
lng: geo.data.location.lng,
389+
});
390+
}
391+
392+
// 2. If optimize requested and > 2 stops, use distance-matrix + nearest-neighbor
393+
// Use driving mode for optimization (transit matrix requires departure_time and often returns null)
394+
let orderedStops = geocoded;
395+
if (params.optimize !== false && geocoded.length > 2) {
396+
const matrix = await this.calculateDistanceMatrix(stops, stops, "driving");
397+
if (matrix.success && matrix.data) {
398+
// Nearest-neighbor from first stop
399+
const visited = new Set<number>([0]);
400+
const order = [0];
401+
let current = 0;
402+
while (visited.size < geocoded.length) {
403+
let bestIdx = -1;
404+
let bestDuration = Infinity;
405+
for (let i = 0; i < geocoded.length; i++) {
406+
if (visited.has(i)) continue;
407+
const dur = matrix.data.durations[current]?.[i]?.value ?? Infinity;
408+
if (dur < bestDuration) {
409+
bestDuration = dur;
410+
bestIdx = i;
411+
}
412+
}
413+
if (bestIdx === -1) break;
414+
visited.add(bestIdx);
415+
order.push(bestIdx);
416+
current = bestIdx;
417+
}
418+
orderedStops = order.map((i) => geocoded[i]);
419+
}
420+
}
421+
422+
// 3. Get directions between consecutive stops (use original names for reliable results)
423+
const legs: any[] = [];
424+
let totalDistance = 0;
425+
let totalDuration = 0;
426+
for (let i = 0; i < orderedStops.length - 1; i++) {
427+
const dir = await this.getDirections(orderedStops[i].originalName, orderedStops[i + 1].originalName, mode);
428+
if (dir.success && dir.data) {
429+
totalDistance += dir.data.total_distance.value;
430+
totalDuration += dir.data.total_duration.value;
431+
legs.push({
432+
from: orderedStops[i].originalName,
433+
to: orderedStops[i + 1].originalName,
434+
distance: dir.data.total_distance.text,
435+
duration: dir.data.total_duration.text,
436+
});
437+
} else {
438+
legs.push({
439+
from: orderedStops[i].originalName,
440+
to: orderedStops[i + 1].originalName,
441+
distance: "unknown",
442+
duration: "unknown",
443+
note: dir.error || "Directions unavailable for this segment",
444+
});
445+
}
446+
}
447+
448+
return {
449+
success: true,
450+
data: {
451+
mode,
452+
optimized: params.optimize !== false && geocoded.length > 2,
453+
stops: orderedStops.map((s) => `${s.originalName} (${s.address})`),
454+
legs,
455+
total_distance: `${(totalDistance / 1000).toFixed(1)} km`,
456+
total_duration: `${Math.round(totalDuration / 60)} min`,
457+
},
458+
};
459+
}
460+
461+
async comparePlaces(params: {
462+
query: string;
463+
userLocation?: { latitude: number; longitude: number };
464+
limit?: number;
465+
}): Promise<any> {
466+
const limit = params.limit || 5;
467+
468+
// 1. Search
469+
const search = await this.searchText({ query: params.query });
470+
if (!search.success || !search.data) throw new Error(search.error || "Search failed");
471+
472+
const places = search.data.slice(0, limit);
473+
474+
// 2. Get details for each
475+
const compared: any[] = [];
476+
for (const place of places) {
477+
const details = await this.getPlaceDetails(place.place_id);
478+
compared.push({
479+
name: place.name,
480+
address: place.address,
481+
rating: place.rating,
482+
total_ratings: place.total_ratings,
483+
open_now: place.open_now,
484+
phone: details.data?.phone,
485+
website: details.data?.website,
486+
price_level: details.data?.price_level,
487+
});
488+
}
489+
490+
// 3. Distance from user location (if provided)
491+
if (params.userLocation && compared.length > 0) {
492+
const origin = `${params.userLocation.latitude},${params.userLocation.longitude}`;
493+
const destinations = places.map((p: any) => `${p.location.lat},${p.location.lng}`);
494+
const matrix = await this.calculateDistanceMatrix([origin], destinations, "driving");
495+
if (matrix.success && matrix.data) {
496+
for (let i = 0; i < compared.length; i++) {
497+
compared[i].distance = matrix.data.distances[0]?.[i]?.text;
498+
compared[i].drive_time = matrix.data.durations[0]?.[i]?.text;
499+
}
500+
}
501+
}
502+
503+
return { success: true, data: compared };
504+
}
505+
319506
async getElevation(locations: Array<{ latitude: number; longitude: number }>): Promise<ElevationResponse> {
320507
try {
321508
const result = await this.mapsTools.getElevation(locations);

src/tools/maps/comparePlaces.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { z } from "zod";
2+
import { PlacesSearcher } from "../../services/PlacesSearcher.js";
3+
import { getCurrentApiKey } from "../../utils/requestContext.js";
4+
5+
const NAME = "maps_compare_places";
6+
const DESCRIPTION =
7+
"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.";
8+
9+
const SCHEMA = {
10+
query: z.string().describe("Search query (e.g., 'ramen near Shibuya', 'hotels in Taipei')"),
11+
userLocation: z
12+
.object({
13+
latitude: z.number().describe("Your latitude"),
14+
longitude: z.number().describe("Your longitude"),
15+
})
16+
.optional()
17+
.describe("Your current location — if provided, adds distance and drive time to each result"),
18+
limit: z.number().optional().describe("Max places to compare (default: 5)"),
19+
};
20+
21+
export type ComparePlacesParams = z.infer<z.ZodObject<typeof SCHEMA>>;
22+
23+
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
24+
try {
25+
const apiKey = getCurrentApiKey();
26+
const searcher = new PlacesSearcher(apiKey);
27+
const result = await searcher.comparePlaces(params);
28+
29+
return {
30+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
31+
isError: false,
32+
};
33+
} catch (error: any) {
34+
return {
35+
isError: true,
36+
content: [{ type: "text", text: `Error comparing places: ${error.message}` }],
37+
};
38+
}
39+
}
40+
41+
export const ComparePlaces = { NAME, DESCRIPTION, SCHEMA, ACTION };

src/tools/maps/exploreArea.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { z } from "zod";
2+
import { PlacesSearcher } from "../../services/PlacesSearcher.js";
3+
import { getCurrentApiKey } from "../../utils/requestContext.js";
4+
5+
const NAME = "maps_explore_area";
6+
const DESCRIPTION =
7+
"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.";
8+
9+
const SCHEMA = {
10+
location: z.string().describe("Address or landmark to explore around"),
11+
types: z
12+
.array(z.string())
13+
.optional()
14+
.describe("Place types to search (default: restaurant, cafe, attraction). Examples: hotel, bar, park, museum"),
15+
radius: z.number().optional().describe("Search radius in meters (default: 1000)"),
16+
topN: z.number().optional().describe("Number of top results per type to get details for (default: 3)"),
17+
};
18+
19+
export type ExploreAreaParams = z.infer<z.ZodObject<typeof SCHEMA>>;
20+
21+
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
22+
try {
23+
const apiKey = getCurrentApiKey();
24+
const searcher = new PlacesSearcher(apiKey);
25+
const result = await searcher.exploreArea(params);
26+
27+
return {
28+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
29+
isError: false,
30+
};
31+
} catch (error: any) {
32+
return {
33+
isError: true,
34+
content: [{ type: "text", text: `Error exploring area: ${error.message}` }],
35+
};
36+
}
37+
}
38+
39+
export const ExploreArea = { NAME, DESCRIPTION, SCHEMA, ACTION };

src/tools/maps/planRoute.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { z } from "zod";
2+
import { PlacesSearcher } from "../../services/PlacesSearcher.js";
3+
import { getCurrentApiKey } from "../../utils/requestContext.js";
4+
5+
const NAME = "maps_plan_route";
6+
const DESCRIPTION =
7+
"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.";
8+
9+
const SCHEMA = {
10+
stops: z.array(z.string()).min(2).describe("List of addresses or landmarks to visit (minimum 2)"),
11+
mode: z.enum(["driving", "walking", "bicycling", "transit"]).optional().describe("Travel mode (default: driving)"),
12+
optimize: z
13+
.boolean()
14+
.optional()
15+
.describe("Auto-optimize visit order by nearest-neighbor (default: true). Set false to keep original order."),
16+
};
17+
18+
export type PlanRouteParams = z.infer<z.ZodObject<typeof SCHEMA>>;
19+
20+
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
21+
try {
22+
const apiKey = getCurrentApiKey();
23+
const searcher = new PlacesSearcher(apiKey);
24+
const result = await searcher.planRoute(params);
25+
26+
return {
27+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
28+
isError: false,
29+
};
30+
} catch (error: any) {
31+
return {
32+
isError: true,
33+
content: [{ type: "text", text: `Error planning route: ${error.message}` }],
34+
};
35+
}
36+
}
37+
38+
export const PlanRoute = { NAME, DESCRIPTION, SCHEMA, ACTION };

0 commit comments

Comments
 (0)