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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" |
Expand Down
27 changes: 23 additions & 4 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 lorsque `profile` vaut `car` ou `pedestrian`.
(source : Géoplateforme (calcul d'itinéraire)).
```

### Schéma d’entrée
Expand All @@ -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, précision à 0.5cm), `car` en voiture, `pedestrian` à pied. Valeurs : spherical, ellipsoidal, car, pedestrian. Valeur par défaut : spherical. |

<details>
<summary>Schéma d’entrée brut</summary>
Expand Down Expand Up @@ -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, précision à 0.5cm), `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": [
Expand All @@ -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`. |

<details>
<summary>Schéma de sortie brut</summary>
Expand All @@ -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": [
Expand Down
93 changes: 93 additions & 0 deletions src/gpf/itinerary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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, 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";
// 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;

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<ItineraryResponse> {
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);
},
};
8 changes: 3 additions & 5 deletions src/gpf/navigation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down
16 changes: 16 additions & 0 deletions src/gpf/navigationRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 35 additions & 6 deletions src/tools/DistanceTool.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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).",
" `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")
.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 ---
Expand All @@ -41,11 +53,15 @@ type DistanceInput = z.infer<typeof distanceInputSchema>;

// --- 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 lorsque \`profile\` vaut \`car\` ou \`pedestrian\`.`,
`(source : ${NAVIGATION_ITINERARY_SOURCE}).`,
].join("\n");

class DistanceTool extends BaseTool<DistanceInput> {
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;
Expand All @@ -62,7 +78,7 @@ class DistanceTool extends BaseTool<DistanceInput> {
* 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} ...`, {
Expand All @@ -81,6 +97,19 @@ class DistanceTool extends BaseTool<DistanceInput> {
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: Math.round(itinerary.distance * 100) / 100,
time: Math.round(itinerary.duration * 10) / 10
};
}
default: {
const profile: never = input.profile;
throw new Error(`Impossible profile ${profile}`);
Expand Down
Loading
Loading