Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/services/PlacesSearcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,8 +529,8 @@ export class PlacesSearcher {
const origin = stops[0];
const destination = stops[stops.length - 1];
const intermediates = stops.length > 2 ? stops.slice(1, -1) : undefined;
// Optimize if requested, > 2 stops, and not transit (transit doesn't support intermediates for optimization)
const shouldOptimize = params.optimize !== false && stops.length > 2 && mode !== "transit";
// Optimize if requested, > 3 stops (at least 2 intermediates), and not transit (transit doesn't support intermediates for optimization)
const shouldOptimize = params.optimize !== false && stops.length > 3 && mode !== "transit";

const routeResult = await this.routesService.computeRoutes({
origin,
Expand Down
4 changes: 2 additions & 2 deletions src/services/RoutesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ export class RoutesService {
requestBody.intermediates = params.intermediates.map(toWaypoint);
}

// Waypoint optimization (not supported for TRANSIT)
// Waypoint optimization (not supported for TRANSIT, requires at least 2 intermediates)
if (
params.optimizeWaypointOrder &&
params.intermediates &&
params.intermediates.length > 0 &&
params.intermediates.length > 1 &&
travelMode !== "TRANSIT"
) {
requestBody.optimizeWaypointOrder = true;
Expand Down
4 changes: 2 additions & 2 deletions src/tools/maps/planRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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, uses Routes API waypoint optimization (up to 25 intermediate stops) to find the most efficient visit order, and returns directions for each leg. 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. For multi-day trips: create one plan_route call per day with stops that follow a geographic arc (e.g. east→west) rather than mixing distant areas. After results, call static_map to visualize the route.";
"Plan an optimized multi-stop route in one call — geocodes all stops, uses Routes API waypoint optimization (2 to 25 intermediate stops) to find the most efficient visit order, and returns directions for each leg. 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. Waypoint optimization requires at least 4 stops (2 intermediates); with 2 or 3 stops the route is returned in the original order. For multi-day trips: create one plan_route call per day with stops that follow a geographic arc (e.g. east→west) rather than mixing distant areas. After results, call static_map to visualize the route.";

const SCHEMA = {
stops: z.array(z.string()).min(2).describe("List of addresses or landmarks to visit (minimum 2)"),
Expand All @@ -13,7 +13,7 @@ const SCHEMA = {
.boolean()
.optional()
.describe(
"Auto-optimize visit order via Routes API waypoint optimization (default: true). Set false to keep original order. Not available for transit mode."
"Auto-optimize visit order via Routes API waypoint optimization (default: true). Requires at least 4 stops (2 intermediates) — ignored for 2-3 stops. Set false to keep original order. Not available for transit mode."
),
departure_time: z
.string()
Expand Down
106 changes: 106 additions & 0 deletions tests/planRoute.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import { PlacesSearcher } from "../src/services/PlacesSearcher.js";

interface RouteStub {
optimizedIntermediateWaypointIndex?: number[];
legCount: number;
}

/**
* Build a PlacesSearcher whose network dependencies are stubbed so planRoute
* can be exercised without hitting the Geocoding or Routes APIs.
*/
function createSearcher(routeStub: RouteStub): PlacesSearcher {
const searcher = new PlacesSearcher("test-api-key");

// Deterministic geocode: echo the requested stop as originalName.
searcher.geocode = async (address: string) => ({
success: true,
data: {
location: { lat: 0, lng: 0 },
formatted_address: `${address} (formatted)`,
place_id: `place-${address}`,
},
});

// Stub the Routes API response.
(
searcher as unknown as { routesService: { computeRoutes: (p: unknown) => Promise<unknown> } }
).routesService.computeRoutes = async () => ({
routes: [
{
legs: Array.from({ length: routeStub.legCount }, () => ({
distanceMeters: 1000,
duration: "600s",
})),
},
],
summary: "",
total_distance: { value: 0, text: "" },
total_duration: { value: 0, text: "" },
arrival_time: "",
departure_time: "",
...(routeStub.optimizedIntermediateWaypointIndex
? { optimizedIntermediateWaypointIndex: routeStub.optimizedIntermediateWaypointIndex }
: {}),
});

return searcher;
}

test("planRoute handles 2 stops (optimize: true, no intermediates)", async () => {
const searcher = createSearcher({ legCount: 1 });
const result = await searcher.planRoute({ stops: ["A", "B"] });

assert.equal(result.data.optimized, false); // <= 3 stops => no optimization
assert.deepEqual(result.data.stops, ["A (A (formatted))", "B (B (formatted))"]);
assert.equal(result.data.legs.length, 1);
});

test("planRoute handles 2 stops (optimize: false)", async () => {
const searcher = createSearcher({ legCount: 1 });
const result = await searcher.planRoute({ stops: ["A", "B"], optimize: false });

assert.equal(result.data.optimized, false);
assert.equal(result.data.legs.length, 1);
});

test("planRoute handles 3 stops (optimize: true) — skips optimization (too few intermediates)", async () => {
// Optimization is skipped because it requires at least 4 stops (2 intermediates)
const searcher = createSearcher({ legCount: 2 });
const result = await searcher.planRoute({ stops: ["A", "B", "C"], optimize: true });

assert.equal(result.data.optimized, false);
assert.deepEqual(
result.data.stops.map((s: string) => s.split(" (")[0]),
["A", "B", "C"]
);
assert.equal(result.data.legs.length, 2);
});

test("planRoute handles 3 stops (optimize: false) — keeps original order", async () => {
const searcher = createSearcher({ legCount: 2 });
const result = await searcher.planRoute({ stops: ["A", "B", "C"], optimize: false });

assert.equal(result.data.optimized, false);
assert.deepEqual(
result.data.stops.map((s: string) => s.split(" (")[0]),
["A", "B", "C"]
);
});

test("planRoute applies a valid optimized waypoint order (4 stops / 2 intermediates)", async () => {
const searcher = createSearcher({ legCount: 3, optimizedIntermediateWaypointIndex: [1, 0] });
const result = await searcher.planRoute({
stops: ["Start", "I0", "I1", "End"],
optimize: true,
});

assert.equal(result.data.optimized, true);
assert.deepEqual(
result.data.stops.map((s: string) => s.split(" (")[0]),
["Start", "I1", "I0", "End"]
);
assert.equal(result.data.legs.length, 3);
});
89 changes: 89 additions & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,94 @@ async function testTransitDetailsField(session: McpSession): Promise<void> {
);
}

async function testPlanRoute(session: McpSession): Promise<void> {
console.log("\n🧪 Test 9: Plan route (waypoint optimization thresholds)");

if (!API_KEY) {
console.log(" ⏭️ Skipped (no GOOGLE_MAPS_API_KEY)");
return;
}

// Case A: 3 stops (1 intermediate) — optimization must be bypassed.
// Regression guard: the Routes API returns an unusable
// optimizedIntermediateWaypointIndex of [-1] for a single intermediate, which
// previously crashed with "Cannot read properties of undefined (reading 'originalName')".
const threeStops = ["Shibuya Station, Tokyo", "Tokyo Tower", "Shinjuku Station, Tokyo"];
const threeStopResult = await sendRequest(session, "tools/call", {
name: "maps_plan_route",
arguments: { stops: threeStops, mode: "driving", optimize: true },
});

const threeContent = threeStopResult?.result?.content ?? [];
assert(threeContent.length > 0, "plan_route (3 stops) returns content");

if (threeContent.length > 0) {
const text = threeContent[0]?.text ?? "";
const isError = threeStopResult?.result?.isError === true;

assert(!isError, "plan_route (3 stops, optimize: true) succeeds", `got: ${text.slice(0, 200)}`);
assert(!text.includes("originalName"), "plan_route (3 stops) does not surface an originalName crash");

if (!isError) {
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
assert(false, "plan_route (3 stops) returns valid JSON", `got: ${text.slice(0, 200)}`);
return;
}

assert(parsed?.optimized === false, "plan_route (3 stops) skips optimization", `optimized: ${parsed?.optimized}`);
assert(parsed?.stops?.length === 3, "plan_route (3 stops) returns 3 stops", `got: ${parsed?.stops?.length}`);
assert(parsed?.legs?.length === 2, "plan_route (3 stops) returns 2 legs", `got: ${parsed?.legs?.length}`);
assert(
threeStops.every((stop, i) => (parsed?.stops?.[i] ?? "").startsWith(stop)),
"plan_route (3 stops) preserves the original stop order",
`got: ${JSON.stringify(parsed?.stops)}`
);
}
}

// Case B: 4 stops (2 intermediates) — optimization must engage.
const fourStops = ["Tokyo Station", "Ueno Park", "Asakusa", "Shibuya Crossing"];
const fourStopResult = await sendRequest(session, "tools/call", {
name: "maps_plan_route",
arguments: { stops: fourStops, mode: "driving", optimize: true },
});

const fourContent = fourStopResult?.result?.content ?? [];
assert(fourContent.length > 0, "plan_route (4 stops) returns content");
if (fourContent.length === 0) return;

const fourText = fourContent[0]?.text ?? "";
const fourIsError = fourStopResult?.result?.isError === true;
assert(!fourIsError, "plan_route (4 stops, optimize: true) succeeds", `got: ${fourText.slice(0, 200)}`);
if (fourIsError) return;

let fourParsed: any;
try {
fourParsed = JSON.parse(fourText);
} catch {
assert(false, "plan_route (4 stops) returns valid JSON", `got: ${fourText.slice(0, 200)}`);
return;
}

assert(
fourParsed?.optimized === true,
"plan_route (4 stops) applies waypoint optimization",
`optimized: ${fourParsed?.optimized}`
);
assert(fourParsed?.stops?.length === 4, "plan_route (4 stops) returns 4 stops", `got: ${fourParsed?.stops?.length}`);
assert(fourParsed?.legs?.length === 3, "plan_route (4 stops) returns 3 legs", `got: ${fourParsed?.legs?.length}`);
// The optimized order is decided by Google, so only assert every stop survived
// the geocode → optimized-order remap (no dropped or undefined entries).
assert(
fourStops.every((stop) => (fourParsed?.stops ?? []).some((s: string) => s.startsWith(stop))),
"plan_route (4 stops) retains every requested stop after optimization",
`got: ${JSON.stringify(fourParsed?.stops)}`
);
}

// --------------- Main ---------------

async function main() {
Expand All @@ -1173,6 +1261,7 @@ async function main() {
await testPlaceDetailsPhotos(session);
await testTransitErrorMessages(session);
await testTransitDetailsField(session);
await testPlanRoute(session);
await testMultiSession();
} catch (err) {
console.error("\n💥 Fatal error:", err);
Expand Down