From d6af5fffe7499e22b181cf040d12bced2fc3e638 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Wed, 19 Aug 2026 15:10:33 +0200 Subject: [PATCH 1/3] feat: Implement itinerary services --- README.md | 1 + docs/config.md | 2 +- src/gpf/itinerary.ts | 91 ++++++++++++++++++++++++++++++++ src/gpf/navigation.ts | 8 ++- src/gpf/navigationRateLimiter.ts | 16 ++++++ test/gpf/itinerary.test.ts | 84 +++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 src/gpf/itinerary.ts create mode 100644 src/gpf/navigationRateLimiter.ts create mode 100644 test/gpf/itinerary.test.ts diff --git a/README.md b/README.md index ee85eeb6..92d98258 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | -------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | Géocoder un lieu | `geocode` | [Autocomplétion Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/autocompletion/) | Localiser une mairie | | Obtenir une altitude | `altitude` | [Calcul altimétrique Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-altimetrique/) | Altitude d'un point | +| Calculer une distance ou un temps de trajet | `distance` | [Calcul d'itinéraire Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-itineraire/) | Temps de trajet à pied | | Récupérer le contexte administratif | `adminexpress` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [ADMIN-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_ADMIN-EXPRESS) | Commune, département, région | | Récupérer le cadastre | `cadastre` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [PARCELLAIRE-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_PARCELLAIRE-EXPRESS-PCI) | Parcelle cadastrale | | Récupérer les documents d'urbanisme | `urbanisme` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | PLU, POS, CC | diff --git a/docs/config.md b/docs/config.md index d076cabd..0a00ad26 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,7 +18,7 @@ | `GPF_WFS_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le WFS de la Géoplateforme. | 30 | | `GPF_GEOCODE_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'autocomplétion de la Géoplateforme. | 50 | | `GPF_ALTI_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'altimétrie de la Géoplateforme. | 50 | -| `GPF_NAVIGATION_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'isochrone/navigation de la Géoplateforme. | 5 | +| `GPF_NAVIGATION_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'isochrone/navigation de la Géoplateforme. Budget partagé entre les appels isochrone et itinéraire. | 5 | | `GPF_WFS_MINISEARCH_OPTIONS` | Chaîne JSON optionnelle permettant de configurer `gpf_search_types`. | options par défaut de `@ignfab/gpf-schema-store` | | `LOG_FORMAT` | Le format d'écriture des logs : "json" ou "simple". | "simple" | | `LOG_LEVEL` | Le niveau d'écriture des logs : ["error", "info", ou "debug"](https://github.com/winstonjs/winston#logging-levels) | "debug" | diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts new file mode 100644 index 00000000..c16ed601 --- /dev/null +++ b/src/gpf/itinerary.ts @@ -0,0 +1,91 @@ +import { fetchJSONGet } from "../helpers/http.js"; +import logger from "../logger.js"; +import type { JsonFetcher } from "../helpers/http.js"; +import type { RateLimiter } from "../helpers/RateLimiter.js"; +import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; +import { TRAVEL_TIME_PROFILES } from "./navigation.js"; + +export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)"; +export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire"; +export const ITINERARY_RESOURCE = "bdtopo-osrm"; +export const ITINERARY_PROFILES = TRAVEL_TIME_PROFILES; +export const ITINERARY_METRICS = ["time", "distance"] as const; + +export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; +export type ItineraryMetric = typeof ITINERARY_METRICS[number]; + +type ItineraryResponse = { + distance: number; + duration: number; +}; + +export type ItineraryInput = { + departure: { + lon: number; + lat: number; + }; + arrival: { + lon: number; + lat: number; + }; + profile: ItineraryProfile; + optimize?: ItineraryMetric; +}; + +/** + * Builds the itinerary request URL. + */ +function buildItineraryUrl(input: ItineraryInput, geometryFormat: "polyline" | "geojson"): string { + return `${NAVIGATION_ITINERARY_URL}?${new URLSearchParams({ + resource: ITINERARY_RESOURCE, + start: `${input.departure.lon},${input.departure.lat}`, + end: `${input.arrival.lon},${input.arrival.lat}`, + profile: input.profile, + optimization: input.optimize === "distance" ? "shortest" : "fastest", + timeUnit: "minute", + distanceUnit: "meter", + crs: "EPSG:4326", + geometryFormat, + getSteps: "false", + getBbox: "false", + }).toString()}`; +} + +/** + * Validates the distance and duration returned by the itinerary service. + */ +function parseItineraryCosts(distance: unknown, duration: unknown): ItineraryResponse { + if (typeof distance !== "number" || typeof duration !== "number") { + throw new Error("Le service d'itinéraire n'a pas renvoyé de distance et de durée exploitables."); + } + return { distance, duration }; +} + +export class NavigationItineraryClient { + constructor( + private rateLimiter: RateLimiter, + private fetcher: JsonFetcher<{distance?: unknown; duration?: unknown}> = fetchJSONGet, + ) {} + + async getItinerary(input: ItineraryInput): Promise { + await this.rateLimiter.limit(); + logger.debug(`[gpf:navigation] getItinerary(${JSON.stringify(input)})...`); + + // polyline format minimizes response size; geometry is discarded anyway + const result = await this.fetcher(buildItineraryUrl(input, "polyline")); + return parseItineraryCosts(result.distance, result.duration); + } +} + +let defaultNavigationItineraryClient: NavigationItineraryClient | undefined; + +function getDefaultNavigationItineraryClient() { + defaultNavigationItineraryClient ??= new NavigationItineraryClient(getNavigationRateLimiter()); + return defaultNavigationItineraryClient; +} + +export const navigationItineraryClient = { + getItinerary(input: ItineraryInput) { + return getDefaultNavigationItineraryClient().getItinerary(input); + }, +}; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 52fc3fd9..6c8668aa 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -1,10 +1,10 @@ import { fetchJSONGet } from "../helpers/http.js"; import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; -import { RateLimiter } from "../helpers/RateLimiter.js"; -import { getEnv } from "../config/env.js"; import type { Geometry } from "geojson"; import { isGeometryLike } from "../helpers/geojson.js"; +import type { RateLimiter } from "../helpers/RateLimiter.js"; +import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; @@ -57,9 +57,7 @@ export class NavigationIsochroneClient { let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined; function getDefaultNavigationIsochroneClient() { - defaultNavigationIsochroneClient ??= new NavigationIsochroneClient( - new RateLimiter({ name: "GPF_NAVIGATION", maxCalls: getEnv().GPF_NAVIGATION_RATE_LIMIT, period: 1 }), - ); + defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter()); return defaultNavigationIsochroneClient; } diff --git a/src/gpf/navigationRateLimiter.ts b/src/gpf/navigationRateLimiter.ts new file mode 100644 index 00000000..2da47202 --- /dev/null +++ b/src/gpf/navigationRateLimiter.ts @@ -0,0 +1,16 @@ +import { RateLimiter } from "../helpers/RateLimiter.js"; +import { getEnv } from "../config/env.js"; + +// Isochrone (navigation.ts) and itinerary (itinerary.ts) calls both hit the +// data.geopf.fr/navigation service and share the same GPF_NAVIGATION_RATE_LIMIT +// budget, so they share one RateLimiter instance. +let sharedNavigationRateLimiter: RateLimiter | undefined; + +export function getNavigationRateLimiter(): RateLimiter { + sharedNavigationRateLimiter ??= new RateLimiter({ + name: "GPF_NAVIGATION", + maxCalls: getEnv().GPF_NAVIGATION_RATE_LIMIT, + period: 1, + }); + return sharedNavigationRateLimiter; +} diff --git a/test/gpf/itinerary.test.ts b/test/gpf/itinerary.test.ts new file mode 100644 index 00000000..46bb3682 --- /dev/null +++ b/test/gpf/itinerary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { NavigationItineraryClient } from "../../src/gpf/itinerary.js"; +import { RateLimiter } from "../../src/helpers/RateLimiter.js"; + +describe("NavigationItineraryClient", () => { + it("should build an itinerary request and return distance and duration", async () => { + const urls: string[] = []; + const client = new NavigationItineraryClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async (url) => { + urls.push(url); + return { + distance: 395174, + duration: 212, + }; + }, + ); + + const itinerary = await client.getItinerary({ + departure: { // 117 rue de Paris, 02100 Saint-Quentin + lon: 3.274356, + lat: 49.839862, + }, + arrival: { // 8 place de la République, 21000 Dijon + lon: 5.044572, + lat: 47.326213, + }, + profile: "car", + }); + + expect(itinerary).toEqual({ + distance: 395174, + duration: 212, + }); + + const parsedUrl = new URL(urls[0]); + expect(parsedUrl.origin + parsedUrl.pathname).toEqual("https://data.geopf.fr/navigation/itineraire"); + expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-osrm"); + expect(parsedUrl.searchParams.get("start")).toEqual("3.274356,49.839862"); + expect(parsedUrl.searchParams.get("end")).toEqual("5.044572,47.326213"); + expect(parsedUrl.searchParams.get("profile")).toEqual("car"); + expect(parsedUrl.searchParams.get("optimization")).toEqual("fastest"); + expect(parsedUrl.searchParams.get("timeUnit")).toEqual("minute"); + expect(parsedUrl.searchParams.get("distanceUnit")).toEqual("meter"); + expect(parsedUrl.searchParams.get("crs")).toEqual("EPSG:4326"); + expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("polyline"); + expect(parsedUrl.searchParams.get("getSteps")).toEqual("false"); + expect(parsedUrl.searchParams.get("getBbox")).toEqual("false"); + }); + + it("should request the shortest itinerary when optimize=distance", async () => { + const urls: string[] = []; + const client = new NavigationItineraryClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async (url) => { + urls.push(url); + return { distance: 1000, duration: 10 }; + }, + ); + + await client.getItinerary({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 5.044572, lat: 47.326213 }, + profile: "pedestrian", + optimize: "distance", + }); + + expect(new URL(urls[0]).searchParams.get("optimization")).toEqual("shortest"); + }); + + it("should reject responses without usable distance and duration", async () => { + const client = new NavigationItineraryClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async () => ({ distance: 1000 }), + ); + + await expect(client.getItinerary({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 5.044572, lat: 47.326213 }, + profile: "car", + })).rejects.toThrow("distance et de durée exploitables"); + }); +}); From 26ea39c2273ad40e25950ae75dc088af66e152ad Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Wed, 19 Aug 2026 15:11:05 +0200 Subject: [PATCH 2/3] feat: plug itinerary into distance tool --- docs/mcp-tools.md | 27 +++- src/tools/DistanceTool.ts | 45 +++++- .../level2-agent/level2-agent.test.ts | 10 ++ test/tools/distance.test.ts | 138 ++++++++++++------ 4 files changed, 166 insertions(+), 54 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d1c2045c..4ba2a787 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -2220,12 +2220,14 @@ Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts) ### Titre -Distance entre deux points +Distance et temps de trajet entre deux points ### Description du tool ``` Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude. +Renvoie aussi une estimation du temps de trajet dans le cas où un profil (marche, voiture) est renseigné. +(source : Géoplateforme (calcul d'itinéraire)). ``` ### Schéma d’entrée @@ -2234,7 +2236,8 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e | --- | --- | --- | --- | | `arrival` | object | oui | Le point d'arrivée | | `departure` | object | oui | Le point de départ | -| `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. | +| `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. | +| `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. |
Schéma d’entrée brut @@ -2293,10 +2296,21 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e "type": "string", "enum": [ "spherical", - "ellipsoidal" + "ellipsoidal", + "car", + "pedestrian" ], "default": "spherical", - "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)." + "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." + }, + "optimize": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "default": "time", + "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`." } }, "required": [ @@ -2315,6 +2329,7 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `distance` | number | oui | La distance entre les deux points, en mètres. | +| `time` | number | non | Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`. |
Schéma de sortie brut @@ -2326,6 +2341,10 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e "distance": { "type": "number", "description": "La distance entre les deux points, en mètres." + }, + "time": { + "type": "number", + "description": "Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`." } }, "required": [ diff --git a/src/tools/DistanceTool.ts b/src/tools/DistanceTool.ts index b4a660a5..8b5ae88b 100644 --- a/src/tools/DistanceTool.ts +++ b/src/tools/DistanceTool.ts @@ -1,10 +1,11 @@ /** - * MCP tool exposing the distance between two geographic positions. + * MCP tool exposing the distance and travel time between two geographic positions. */ import BaseTool from "./BaseTool.js"; import { z } from "zod"; +import { NAVIGATION_ITINERARY_SOURCE, navigationItineraryClient, ITINERARY_METRICS, ITINERARY_PROFILES } from "../gpf/itinerary.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import { lonSchema, latSchema } from "../helpers/schemas.js"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; @@ -23,16 +24,27 @@ const distanceInputSchema = z.object({ lat: latSchema.describe("La latitude du point d'arrivée."), }).describe("Le point d'arrivée"), profile: z - .enum(["spherical", "ellipsoidal"]) + .enum(["spherical", "ellipsoidal", ...ITINERARY_PROFILES]) .default("spherical") .describe(["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).", - ].join("")), + "`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.", + ].join(", ")), + optimize: z + .enum(ITINERARY_METRICS) + .default("time") + .describe(["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`." + ].join("")) }).strict(); const distanceOutputSchema = z.object({ distance: z.number().describe("La distance entre les deux points, en mètres."), + time: z.number().optional().describe("Estimation du temps de trajet, en minutes. Absent si `profile=spherical` ou `ellipsoidal`."), }); // --- Types --- @@ -41,11 +53,15 @@ type DistanceInput = z.infer; // --- Tool --- -const DISTANCE_TOOL_DESCRIPTION = `Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`; +const DISTANCE_TOOL_DESCRIPTION = [ + `Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`, + `Renvoie aussi une estimation du temps de trajet dans le cas où un profil (marche, voiture) est renseigné.`, + `(source : ${NAVIGATION_ITINERARY_SOURCE}).`, +].join("\n"); class DistanceTool extends BaseTool { name = "distance"; - title = "Distance entre deux points"; + title = "Distance et temps de trajet entre deux points"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; description = DISTANCE_TOOL_DESCRIPTION; protected outputSchemaShape = distanceOutputSchema; @@ -62,7 +78,7 @@ class DistanceTool extends BaseTool { * Resolves the distance query. * * @param input Normalized tool input. - * @returns The distance. + * @returns The distance, and the travel time for itinerary profiles. */ async execute(input: DistanceInput) { logger.info(`[tool] execute ${this.name} ...`, { @@ -81,6 +97,19 @@ class DistanceTool extends BaseTool { distance: Math.round(raw * 100) / 100 }; } + case "car": + case "pedestrian": { + const itinerary = await navigationItineraryClient.getItinerary({ + departure: input.departure, + arrival: input.arrival, + profile: input.profile, + optimize: input.optimize, + }); + return { + distance: itinerary.distance, + time: Math.round(itinerary.duration) + }; + } default: { const profile: never = input.profile; throw new Error(`Impossible profile ${profile}`); diff --git a/test/integration/level2-agent/level2-agent.test.ts b/test/integration/level2-agent/level2-agent.test.ts index 2d0aeabf..b5b3421b 100644 --- a/test/integration/level2-agent/level2-agent.test.ts +++ b/test/integration/level2-agent/level2-agent.test.ts @@ -90,6 +90,16 @@ const mcpScenarios = [ // assuming that it won't often change and that the number is correct at the moment of writing // (switch to assertScenarioResult with a range if needed in the future) expectedResponseFragments: ["19", "batiments"] + }, + { + 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", + 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 ?", + toolMode: "mcp", + requiredToolCalls: ["geocode", "gpf_search_types", "gpf_describe_type", "gpf_get_features", "distance"], + expectedResponseFragments: ["Sivom", "Mondeville"], + assertScenarioResult: ({ normalizedFinalMessage }) => { + expect(containsNumberInRange(normalizedFinalMessage, 25, 35)).toBe(true); + }, } ] satisfies Level2AgentScenario[]; diff --git a/test/tools/distance.test.ts b/test/tools/distance.test.ts index 1f175d18..71428160 100644 --- a/test/tools/distance.test.ts +++ b/test/tools/distance.test.ts @@ -1,52 +1,106 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; -import DistanceTool from "../../src/tools/DistanceTool"; -import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema"; -import { expectErrorText } from "./helpers/errorAssertions"; +import DistanceTool from "../../src/tools/DistanceTool.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema.js"; +import { expectErrorText } from "./helpers/errorAssertions.js"; +import { navigationItineraryClient } from "../../src/gpf/itinerary.js"; +import { ellipsoidalDistance, haversine } from "../../src/helpers/distance.js"; describe("Test DistanceTool", () => { - const departure = { lon: 2.3522, lat: 48.8566 }; - const arrival = { lon: 2.2945, lat: 48.8584 }; - - it("should publish an optional profile and a distance output schema", () => { - const tool = new DistanceTool(); - expect(tool.toolDefinition.title).toEqual("Distance entre deux points"); - expect(tool.toolDefinition.inputSchema.required).not.toContain("profile"); - expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({ - enum: ["spherical", "ellipsoidal"], - default: "spherical", - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); + const departure = { lon: 2.3522, lat: 48.8566 }; + const arrival = { lon: 2.2945, lat: 48.8584 }; + + afterEach(() => vi.restoreAllMocks()); + + it("should publish an optional profile and a distance output schema", () => { + const tool = new DistanceTool(); + expect(tool.toolDefinition.title).toEqual("Distance et temps de trajet entre deux points"); + expect(tool.toolDefinition.inputSchema.required).not.toContain("profile"); + expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({ + enum: ["spherical", "ellipsoidal", "car", "pedestrian"], + default: "spherical", + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it.each([undefined, "spherical", "ellipsoidal"])("should return a structured distance for profile %s", async (profile) => { + const tool = new DistanceTool(); + const response = await tool.toolCall({ + params: { + name: "distance", + arguments: { departure, arrival, ...(profile && { profile }) }, + }, }); - it.each([undefined, "spherical", "ellipsoidal"])("should return a structured distance for profile %s", async (profile) => { - const tool = new DistanceTool(); - const response = await tool.toolCall({ - params: { - name: "distance", - arguments: { departure, arrival, ...(profile && { profile }) }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent).toMatchObject({ distance: expect.any(Number) }); - expect((response.structuredContent as { distance: number }).distance).toBeGreaterThan(0); - expect(response.content[0]).toMatchObject({ type: "text" }); - expect(validateStructuredContentAgainstOutputSchema( - tool.toolDefinition.outputSchema, - response.structuredContent, - )).toBeNull(); + expect(response.isError).toBeUndefined(); + expect(response.structuredContent).toMatchObject({ distance: expect.any(Number) }); + expect((response.structuredContent as { distance: number }).distance).toBeGreaterThan(0); + expect(response.content[0]).toMatchObject({ type: "text" }); + expect(validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + )).toBeNull(); + }); + + it("should reject invalid coordinates at the tool boundary", async () => { + const tool = new DistanceTool(); + const response = await tool.toolCall({ + params: { + name: "distance", + arguments: { departure: { lon: 600, lat: 48.8566 }, arrival }, + }, + }); + + expect(expectErrorText(response)).toContain("departure.lon: La valeur doit être au plus 180."); + }); + + it.each([ + ["spherical", haversine], + ["ellipsoidal", ellipsoidalDistance], + ] as const)("should return the rounded %s distance", async (profile, calculateDistance) => { + const response = await new DistanceTool().toolCall({ + params: { name: "distance", arguments: { departure, arrival, profile } }, }); + const expected = Math.round(calculateDistance( + [departure.lon, departure.lat], [arrival.lon, arrival.lat], + ) * 100) / 100; - it("should reject invalid coordinates at the tool boundary", async () => { - const tool = new DistanceTool(); - const response = await tool.toolCall({ - params: { - name: "distance", - arguments: { departure: { lon: 600, lat: 48.8566 }, arrival }, - }, - }); + expect(response.isError).toBeUndefined(); + expect(response.structuredContent).toEqual({ distance: expected }); + expect(JSON.parse((response.content[0] as { type: "text"; text: string }).text)).toEqual({ distance: expected }); + }); - expect(expectErrorText(response)).toContain("departure.lon: La valeur doit être au plus 180."); + it.each([ + ["car", 395174, 212, 212], + ["pedestrian", 12345, 67.8, 68], + ] as const)("should return itinerary distance and time for %s", async (profile, distance, duration, time) => { + const tool = new DistanceTool(); + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ distance, duration }); + const response = await tool.toolCall({ + params: { name: "distance", arguments: { departure, arrival, profile } }, }); + + expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile, optimize: "time" }); + expect(response.isError).toBeUndefined(); + expect(response.content[0]).toMatchObject({ type: "text" }); + expect(JSON.parse((response.content[0] as { type: "text"; text: string }).text)).toEqual({ distance, time }); + expect(response.structuredContent).toEqual({ distance, time }); + expect(validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + )).toBeNull(); + }); + + it("should forward optimize=distance to itinerary client", async () => { + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ + distance: 999, + duration: 10, + }); + + await new DistanceTool().toolCall({ + params: { name: "distance", arguments: { departure, arrival, profile: "car", optimize: "distance" } }, + }); + + expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile: "car", optimize: "distance" }); + }); }); From 3a10440e1a4c28c13559ad4cfa44f217e9cef2cf Mon Sep 17 00:00:00 2001 From: "Emmanuel S." <5435148+esgn@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:01:05 +0200 Subject: [PATCH 3/3] 138/review and fix (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(itinerary): route with Valhalla, like the travel time isochrones The distance tool used bdtopo-osrm while travel_time_filter isochrones use bdtopo-valhalla, so the two could disagree by up to 12% on walking times. Reuse TRAVEL_TIME_RESOURCE for the itinerary. * fix(distance): restore the ellipsoidal precision lost in the rebase The rebase brought back "plus précise et coûteuse, précision à 1mm" instead of the 0.5 cm wording merged in #183, and the new ", " join produced "suivi :, `spherical`". Join the profile lines as before. * fix(distance): keep a tenth of a minute in the travel time Rounding to the whole minute turned a 40-second walk into 1 or even 0 minutes. * docs(distance): say which profiles return a travel time `profile` always has a value (`spherical` by default), so "lorsqu'un profil est renseigné" was always true. * fix(distance): round the itinerary distance to the centimeter The spherical and ellipsoidal profiles already round to the centimeter; the itinerary passed the service value through as is. --- docs/mcp-tools.md | 6 +++--- src/gpf/itinerary.ts | 6 ++++-- src/tools/DistanceTool.ts | 16 ++++++++-------- test/gpf/itinerary.test.ts | 2 +- test/tools/distance.test.ts | 8 ++++---- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4ba2a787..2163ef58 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -2226,7 +2226,7 @@ Distance et temps de trajet entre deux points ``` Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude. -Renvoie aussi une estimation du temps de trajet dans le cas où un profil (marche, voiture) est renseigné. +Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou `pedestrian`. (source : Géoplateforme (calcul d'itinéraire)). ``` @@ -2237,7 +2237,7 @@ Renvoie aussi une estimation du temps de trajet dans le cas où un profil (march | `arrival` | object | oui | Le point d'arrivée | | `departure` | object | oui | Le point de départ | | `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. | -| `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. | +| `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), `car` en voiture, `pedestrian` à pied. Valeurs : spherical, ellipsoidal, car, pedestrian. Valeur par défaut : spherical. |
Schéma d’entrée brut @@ -2301,7 +2301,7 @@ Renvoie aussi une estimation du temps de trajet dans le cas où un profil (march "pedestrian" ], "default": "spherical", - "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." + "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), `car` en voiture, `pedestrian` à pied." }, "optimize": { "type": "string", diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index c16ed601..9eba76b7 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -3,11 +3,13 @@ import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; -import { TRAVEL_TIME_PROFILES } from "./navigation.js"; +import { TRAVEL_TIME_PROFILES, TRAVEL_TIME_RESOURCE } from "./navigation.js"; export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)"; export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire"; -export const ITINERARY_RESOURCE = "bdtopo-osrm"; +// Same engine as the `travel_time_filter` isochrones, so that both report the +// same travel times. +export const ITINERARY_RESOURCE = TRAVEL_TIME_RESOURCE; export const ITINERARY_PROFILES = TRAVEL_TIME_PROFILES; export const ITINERARY_METRICS = ["time", "distance"] as const; diff --git a/src/tools/DistanceTool.ts b/src/tools/DistanceTool.ts index 8b5ae88b..d21cc849 100644 --- a/src/tools/DistanceTool.ts +++ b/src/tools/DistanceTool.ts @@ -27,11 +27,11 @@ const distanceInputSchema = z.object({ .enum(["spherical", "ellipsoidal", ...ITINERARY_PROFILES]) .default("spherical") .describe(["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.", - ].join(", ")), + " `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),", + " `car` en voiture,", + " `pedestrian` à pied.", + ].join("")), optimize: z .enum(ITINERARY_METRICS) .default("time") @@ -55,7 +55,7 @@ type DistanceInput = z.infer; const DISTANCE_TOOL_DESCRIPTION = [ `Renvoie la distance (en mètres) entre deux points à partir de leur longitude et latitude.`, - `Renvoie aussi une estimation du temps de trajet dans le cas où un profil (marche, voiture) est renseigné.`, + `Renvoie aussi une estimation du temps de trajet lorsque \`profile\` vaut \`car\` ou \`pedestrian\`.`, `(source : ${NAVIGATION_ITINERARY_SOURCE}).`, ].join("\n"); @@ -106,8 +106,8 @@ class DistanceTool extends BaseTool { optimize: input.optimize, }); return { - distance: itinerary.distance, - time: Math.round(itinerary.duration) + distance: Math.round(itinerary.distance * 100) / 100, + time: Math.round(itinerary.duration * 10) / 10 }; } default: { diff --git a/test/gpf/itinerary.test.ts b/test/gpf/itinerary.test.ts index 46bb3682..a9b552db 100644 --- a/test/gpf/itinerary.test.ts +++ b/test/gpf/itinerary.test.ts @@ -36,7 +36,7 @@ describe("NavigationItineraryClient", () => { const parsedUrl = new URL(urls[0]); expect(parsedUrl.origin + parsedUrl.pathname).toEqual("https://data.geopf.fr/navigation/itineraire"); - expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-osrm"); + expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-valhalla"); expect(parsedUrl.searchParams.get("start")).toEqual("3.274356,49.839862"); expect(parsedUrl.searchParams.get("end")).toEqual("5.044572,47.326213"); expect(parsedUrl.searchParams.get("profile")).toEqual("car"); diff --git a/test/tools/distance.test.ts b/test/tools/distance.test.ts index 71428160..7d575155 100644 --- a/test/tools/distance.test.ts +++ b/test/tools/distance.test.ts @@ -71,11 +71,11 @@ describe("Test DistanceTool", () => { }); it.each([ - ["car", 395174, 212, 212], - ["pedestrian", 12345, 67.8, 68], - ] as const)("should return itinerary distance and time for %s", async (profile, distance, duration, time) => { + ["car", 395174.126, 395174.13, 212, 212], + ["pedestrian", 12345, 12345, 67.83, 67.8], + ] as const)("should return itinerary distance and time for %s", async (profile, rawDistance, distance, duration, time) => { const tool = new DistanceTool(); - const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ distance, duration }); + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockResolvedValue({ distance: rawDistance, duration }); const response = await tool.toolCall({ params: { name: "distance", arguments: { departure, arrival, profile } }, });