Skip to content

Commit e142cbf

Browse files
feat: plug itinerary into distance tool
1 parent d6f9f54 commit e142cbf

4 files changed

Lines changed: 166 additions & 54 deletions

File tree

‎docs/mcp-tools.md‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,12 +2220,14 @@ Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts)
22202220

22212221
### Titre
22222222

2223-
Distance entre deux points
2223+
Distance et temps de trajet entre deux points
22242224

22252225
### Description du tool
22262226

22272227
```
22282228
Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.
2229+
Renvoie aussi une estimation du temps de trajet dans le cas où un profil (marche, voiture) est renseigné.
2230+
(source : Géoplateforme (calcul d'itinéraire)).
22292231
```
22302232

22312233
### Schéma d’entrée
@@ -2234,7 +2236,8 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e
22342236
| --- | --- | --- | --- |
22352237
| `arrival` | object | oui | Le point d'arrivée |
22362238
| `departure` | object | oui | Le point de départ |
2237-
| `profile` | string (enum) | non | Le type de chemin suivi : `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm). Valeurs : spherical, ellipsoidal. Valeur par défaut : spherical. |
2239+
| `optimize` | string (enum) | non | La métrique à optimiser, lorsqu'il y a un choix : `time` chemin le plus rapide, `distance` chemin le plus court. Cette option est sans effet lorsque `profile=spherical` ou `ellipsoidal`. Valeurs : time, distance. Valeur par défaut : time. |
2240+
| `profile` | string (enum) | non | Le type de chemin suivi :, `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise et coûteuse, précision à 1mm), `car` en voiture, `pedestrian` à pied. Valeurs : spherical, ellipsoidal, car, pedestrian. Valeur par défaut : spherical. |
22382241

22392242
<details>
22402243
<summary>Schéma d’entrée brut</summary>
@@ -2293,10 +2296,21 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e
22932296
"type": "string",
22942297
"enum": [
22952298
"spherical",
2296-
"ellipsoidal"
2299+
"ellipsoidal",
2300+
"car",
2301+
"pedestrian"
22972302
],
22982303
"default": "spherical",
2299-
"description": "Le type de chemin suivi : `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm)."
2304+
"description": "Le type de chemin suivi :, `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%), `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise et coûteuse, précision à 1mm), `car` en voiture, `pedestrian` à pied."
2305+
},
2306+
"optimize": {
2307+
"type": "string",
2308+
"enum": [
2309+
"time",
2310+
"distance"
2311+
],
2312+
"default": "time",
2313+
"description": "La métrique à optimiser, lorsqu'il y a un choix : `time` chemin le plus rapide, `distance` chemin le plus court. Cette option est sans effet lorsque `profile=spherical` ou `ellipsoidal`."
23002314
}
23012315
},
23022316
"required": [
@@ -2315,6 +2329,7 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e
23152329
| Champ | Type | Requis | Description |
23162330
| --- | --- | --- | --- |
23172331
| `distance` | number | oui | La distance entre les deux points, en mètres. |
2332+
| `time` | number | non | Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`. |
23182333

23192334
<details>
23202335
<summary>Schéma de sortie brut</summary>
@@ -2326,6 +2341,10 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e
23262341
"distance": {
23272342
"type": "number",
23282343
"description": "La distance entre les deux points, en mètres."
2344+
},
2345+
"time": {
2346+
"type": "number",
2347+
"description": "Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`."
23292348
}
23302349
},
23312350
"required": [

‎src/tools/DistanceTool.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/**
2-
* MCP tool exposing the distance between two geographic positions.
2+
* MCP tool exposing the distance and travel time between two geographic positions.
33
*/
44

55
import BaseTool from "./BaseTool.js";
66
import { z } from "zod";
77

8+
import { NAVIGATION_ITINERARY_SOURCE, navigationItineraryClient, ITINERARY_METRICS, ITINERARY_PROFILES } from "../gpf/itinerary.js";
89
import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
910
import { lonSchema, latSchema } from "../helpers/schemas.js";
1011
import { generatePublishedInputSchema } from "../helpers/jsonSchema.js";
@@ -23,16 +24,27 @@ const distanceInputSchema = z.object({
2324
lat: latSchema.describe("La latitude du point d'arrivée."),
2425
}).describe("Le point d'arrivée"),
2526
profile: z
26-
.enum(["spherical", "ellipsoidal"])
27+
.enum(["spherical", "ellipsoidal", ...ITINERARY_PROFILES])
2728
.default("spherical")
2829
.describe(["Le type de chemin suivi :",
29-
" `spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%),",
30-
" `ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise, précision à 0.5cm).",
31-
].join("")),
30+
"`spherical` distance à vol d'oiseau (Terre ronde, précision à 0.5%)",
31+
"`ellipsoidal` distance à vol d'oiseau (Terre ellipsoïde, plus précise et coûteuse, précision à 1mm)",
32+
"`car` en voiture",
33+
"`pedestrian` à pied.",
34+
].join(", ")),
35+
optimize: z
36+
.enum(ITINERARY_METRICS)
37+
.default("time")
38+
.describe(["La métrique à optimiser, lorsqu'il y a un choix :",
39+
" `time` chemin le plus rapide,",
40+
" `distance` chemin le plus court.",
41+
" Cette option est sans effet lorsque `profile=spherical` ou `ellipsoidal`."
42+
].join(""))
3243
}).strict();
3344

3445
const distanceOutputSchema = z.object({
3546
distance: z.number().describe("La distance entre les deux points, en mètres."),
47+
time: z.number().optional().describe("Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`."),
3648
});
3749

3850
// --- Types ---
@@ -41,11 +53,15 @@ type DistanceInput = z.infer<typeof distanceInputSchema>;
4153

4254
// --- Tool ---
4355

44-
const DISTANCE_TOOL_DESCRIPTION = `Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`;
56+
const DISTANCE_TOOL_DESCRIPTION = [
57+
`Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`,
58+
`Renvoie aussi une estimation du temps de trajet dans le cas où un profil (marche, voiture) est renseigné.`,
59+
`(source : ${NAVIGATION_ITINERARY_SOURCE}).`,
60+
].join("\n");
4561

4662
class DistanceTool extends BaseTool<DistanceInput> {
4763
name = "distance";
48-
title = "Distance entre deux points";
64+
title = "Distance et temps de trajet entre deux points";
4965
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
5066
description = DISTANCE_TOOL_DESCRIPTION;
5167
protected outputSchemaShape = distanceOutputSchema;
@@ -62,7 +78,7 @@ class DistanceTool extends BaseTool<DistanceInput> {
6278
* Resolves the distance query.
6379
*
6480
* @param input Normalized tool input.
65-
* @returns The distance.
81+
* @returns The distance, and the travel time for itinerary profiles.
6682
*/
6783
async execute(input: DistanceInput) {
6884
logger.info(`[tool] execute ${this.name} ...`, {
@@ -81,6 +97,19 @@ class DistanceTool extends BaseTool<DistanceInput> {
8197
distance: Math.round(raw * 100) / 100
8298
};
8399
}
100+
case "car":
101+
case "pedestrian": {
102+
const itinerary = await navigationItineraryClient.getItinerary({
103+
departure: input.departure,
104+
arrival: input.arrival,
105+
profile: input.profile,
106+
optimize: input.optimize,
107+
});
108+
return {
109+
distance: itinerary.distance,
110+
time: Math.round(itinerary.duration)
111+
};
112+
}
84113
default: {
85114
const profile: never = input.profile;
86115
throw new Error(`Impossible profile ${profile}`);

‎test/integration/level2-agent/level2-agent.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ const mcpScenarios = [
9090
// assuming that it won't often change and that the number is correct at the moment of writing
9191
// (switch to assertScenarioResult with a range if needed in the future)
9292
expectedResponseFragments: ["19", "batiments"]
93+
},
94+
{
95+
testName: "should find that Sivom swimming pool in Mondeville is the nearest pool to the LUX cinema in Caen, and that the walking distance is 29 minutes",
96+
userInput: "Quel est le temps de marche exact entre le cinéma LUX, situé au sud-est de Caen, et la piscine la plus proche ?",
97+
toolMode: "mcp",
98+
requiredToolCalls: ["geocode", "gpf_search_types", "gpf_describe_type", "gpf_get_features", "distance"],
99+
expectedResponseFragments: ["Sivom", "Mondeville"],
100+
assertScenarioResult: ({ normalizedFinalMessage }) => {
101+
expect(containsNumberInRange(normalizedFinalMessage, 25, 35)).toBe(true);
102+
},
93103
}
94104
] satisfies Level2AgentScenario[];
95105

‎test/tools/distance.test.ts‎

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,106 @@
1-
import { describe, it, expect } from "vitest";
1+
import { afterEach, describe, it, expect, vi } from "vitest";
22

3-
import DistanceTool from "../../src/tools/DistanceTool";
4-
import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema";
5-
import { expectErrorText } from "./helpers/errorAssertions";
3+
import DistanceTool from "../../src/tools/DistanceTool.js";
4+
import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema.js";
5+
import { expectErrorText } from "./helpers/errorAssertions.js";
6+
import { navigationItineraryClient } from "../../src/gpf/itinerary.js";
7+
import { ellipsoidalDistance, haversine } from "../../src/helpers/distance.js";
68

79
describe("Test DistanceTool", () => {
8-
const departure = { lon: 2.3522, lat: 48.8566 };
9-
const arrival = { lon: 2.2945, lat: 48.8584 };
10-
11-
it("should publish an optional profile and a distance output schema", () => {
12-
const tool = new DistanceTool();
13-
expect(tool.toolDefinition.title).toEqual("Distance entre deux points");
14-
expect(tool.toolDefinition.inputSchema.required).not.toContain("profile");
15-
expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({
16-
enum: ["spherical", "ellipsoidal"],
17-
default: "spherical",
18-
});
19-
expect(tool.toolDefinition.outputSchema).toBeDefined();
10+
const departure = { lon: 2.3522, lat: 48.8566 };
11+
const arrival = { lon: 2.2945, lat: 48.8584 };
12+
13+
afterEach(() => vi.restoreAllMocks());
14+
15+
it("should publish an optional profile and a distance output schema", () => {
16+
const tool = new DistanceTool();
17+
expect(tool.toolDefinition.title).toEqual("Distance et temps de trajet entre deux points");
18+
expect(tool.toolDefinition.inputSchema.required).not.toContain("profile");
19+
expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({
20+
enum: ["spherical", "ellipsoidal", "car", "pedestrian"],
21+
default: "spherical",
22+
});
23+
expect(tool.toolDefinition.outputSchema).toBeDefined();
24+
});
25+
26+
it.each([undefined, "spherical", "ellipsoidal"])("should return a structured distance for profile %s", async (profile) => {
27+
const tool = new DistanceTool();
28+
const response = await tool.toolCall({
29+
params: {
30+
name: "distance",
31+
arguments: { departure, arrival, ...(profile && { profile }) },
32+
},
2033
});
2134

22-
it.each([undefined, "spherical", "ellipsoidal"])("should return a structured distance for profile %s", async (profile) => {
23-
const tool = new DistanceTool();
24-
const response = await tool.toolCall({
25-
params: {
26-
name: "distance",
27-
arguments: { departure, arrival, ...(profile && { profile }) },
28-
},
29-
});
30-
31-
expect(response.isError).toBeUndefined();
32-
expect(response.structuredContent).toMatchObject({ distance: expect.any(Number) });
33-
expect((response.structuredContent as { distance: number }).distance).toBeGreaterThan(0);
34-
expect(response.content[0]).toMatchObject({ type: "text" });
35-
expect(validateStructuredContentAgainstOutputSchema(
36-
tool.toolDefinition.outputSchema,
37-
response.structuredContent,
38-
)).toBeNull();
35+
expect(response.isError).toBeUndefined();
36+
expect(response.structuredContent).toMatchObject({ distance: expect.any(Number) });
37+
expect((response.structuredContent as { distance: number }).distance).toBeGreaterThan(0);
38+
expect(response.content[0]).toMatchObject({ type: "text" });
39+
expect(validateStructuredContentAgainstOutputSchema(
40+
tool.toolDefinition.outputSchema,
41+
response.structuredContent,
42+
)).toBeNull();
43+
});
44+
45+
it("should reject invalid coordinates at the tool boundary", async () => {
46+
const tool = new DistanceTool();
47+
const response = await tool.toolCall({
48+
params: {
49+
name: "distance",
50+
arguments: { departure: { lon: 600, lat: 48.8566 }, arrival },
51+
},
52+
});
53+
54+
expect(expectErrorText(response)).toContain("departure.lon: La valeur doit être au plus 180.");
55+
});
56+
57+
it.each([
58+
["spherical", haversine],
59+
["ellipsoidal", ellipsoidalDistance],
60+
] as const)("should return the rounded %s distance", async (profile, calculateDistance) => {
61+
const response = await new DistanceTool().toolCall({
62+
params: { name: "distance", arguments: { departure, arrival, profile } },
3963
});
64+
const expected = Math.round(calculateDistance(
65+
[departure.lon, departure.lat], [arrival.lon, arrival.lat],
66+
) * 100) / 100;
4067

41-
it("should reject invalid coordinates at the tool boundary", async () => {
42-
const tool = new DistanceTool();
43-
const response = await tool.toolCall({
44-
params: {
45-
name: "distance",
46-
arguments: { departure: { lon: 600, lat: 48.8566 }, arrival },
47-
},
48-
});
68+
expect(response.isError).toBeUndefined();
69+
expect(response.structuredContent).toEqual({ distance: expected });
70+
expect(JSON.parse((response.content[0] as { type: "text"; text: string }).text)).toEqual({ distance: expected });
71+
});
4972

50-
expect(expectErrorText(response)).toContain("departure.lon: La valeur doit être au plus 180.");
73+
it.each([
74+
["car", 395174, 212, 212],
75+
["pedestrian", 12345, 67.8, 68],
76+
] as const)("should return itinerary distance and time for %s", async (profile, distance, duration, time) => {
77+
const tool = new DistanceTool();
78+
const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ distance, duration });
79+
const response = await tool.toolCall({
80+
params: { name: "distance", arguments: { departure, arrival, profile } },
5181
});
82+
83+
expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile, optimize: "time" });
84+
expect(response.isError).toBeUndefined();
85+
expect(response.content[0]).toMatchObject({ type: "text" });
86+
expect(JSON.parse((response.content[0] as { type: "text"; text: string }).text)).toEqual({ distance, time });
87+
expect(response.structuredContent).toEqual({ distance, time });
88+
expect(validateStructuredContentAgainstOutputSchema(
89+
tool.toolDefinition.outputSchema,
90+
response.structuredContent,
91+
)).toBeNull();
92+
});
93+
94+
it("should forward optimize=distance to itinerary client", async () => {
95+
const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({
96+
distance: 999,
97+
duration: 10,
98+
});
99+
100+
await new DistanceTool().toolCall({
101+
params: { name: "distance", arguments: { departure, arrival, profile: "car", optimize: "distance" } },
102+
});
103+
104+
expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile: "car", optimize: "distance" });
105+
});
52106
});

0 commit comments

Comments
 (0)