diff --git a/README.md b/README.md index 2da189dc..6fddaa33 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | Télécharger le résultat d'une interrogation de couche GPF | `gpf_get_features_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier un résultat | | Télécharger un objet par identifiant | `gpf_get_feature_by_id_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Cartographier un objet | | Télécharger une isochrone ou une isodistance | `gpf_isoline_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | +| Télécharger un itinéraire | `gpf_itinerary_layer` | [itinéraire](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-itineraire/) | Cartographier un trajet | ## Architecture en bref diff --git a/docs/config.md b/docs/config.md index ccd06692..0a529d63 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,12 +28,12 @@ | `PROXY_ENDPOINT` | Chemin exposé par le proxy geodata. | `/api/v1/proxy` | | `PROXY_PUBLIC_BASE_URL` | URL de base publiquement joignable du proxy, utilisée pour construire la `data_url` absolue transmise à Carto. Derrière un reverse-proxy, elle diffère de l'adresse d'écoute ; en développement local, c'est typiquement `http://localhost:3002`. Requise avec `PROXY_URL_SECRET` pour activer les tools `*_layer`. | Aucune | | `GPF_WFS_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le WFS, distincte de `GPF_WFS_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `10` | -| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `isoline` et `gpf_isoline_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` | -| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `isoline`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isoline_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` | +| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service de navigation de la Géoplateforme, distincte de `GPF_NAVIGATION_RATE_LIMIT`. Budget partagé par `isoline_filter` et les couches `gpf_isoline_layer` et `gpf_itinerary_layer`. Les deux limites comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` | +| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS, isochrone et itinéraire), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `isoline`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isoline_layer` et `gpf_itinerary_layer` : une longue isochrone `car` ou un long itinéraire peut le dépasser (erreur 504). | `10` | ## Génération de `PROXY_URL_SECRET` -Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer). +Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isoline_layer`, `gpf_itinerary_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer). La clé doit être une valeur aléatoire de **32 octets encodée en hexadécimal** (soit 64 caractères `0-9a-f`). Générez-la avec : diff --git a/docs/dev.md b/docs/dev.md index a2ffed73..7c10db95 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -60,7 +60,7 @@ Avec certains clients MCP, vous serez amené à éditer un fichier JSON. Par exe ## Activer les tools cartographiques en local -Les tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. +Les tools `gpf_isoline_layer`, `gpf_itinerary_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. Il faut une clé partagée (`PROXY_URL_SECRET`) entre les deux processus, et pointer le MCP vers le proxy geodata local via `PROXY_PUBLIC_BASE_URL`. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index f9a2c6f8..a30dbf5a 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -35,7 +35,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : | `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. | | `destructiveHint` | non | Le tool n'est pas signalé comme destructif. | | `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. | -| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isoline_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | +| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer`, `gpf_isoline_layer` et `gpf_itinerary_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -53,6 +53,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) - [`gpf_isoline_layer`](#gpf_isoline_layer) +- [`gpf_itinerary_layer`](#gpf_itinerary_layer) - [`distance`](#distance) ## `geocode` @@ -190,8 +191,8 @@ Renvoie l'altitude (en mètres) et la précision de la mesure (accuracy) d'un po | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `lat` | number | oui | La latitude du point. | -| `lon` | number | oui | La longitude du point. | +| `lat` | number | oui | Latitude du point en WGS84. | +| `lon` | number | oui | Longitude du point en WGS84. |
Schéma d’entrée brut @@ -202,13 +203,13 @@ Renvoie l'altitude (en mètres) et la précision de la mesure (accuracy) d'un po "properties": { "lon": { "type": "number", - "description": "La longitude du point.", + "description": "Longitude du point en WGS84.", "minimum": -180, "maximum": 180 }, "lat": { "type": "number", - "description": "La latitude du point.", + "description": "Latitude du point en WGS84.", "minimum": -90, "maximum": 90 } @@ -295,8 +296,8 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `lat` | number | oui | La latitude du point. | -| `lon` | number | oui | La longitude du point. | +| `lat` | number | oui | Latitude du point en WGS84. | +| `lon` | number | oui | Longitude du point en WGS84. |
Schéma d’entrée brut @@ -307,13 +308,13 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp "properties": { "lon": { "type": "number", - "description": "La longitude du point.", + "description": "Longitude du point en WGS84.", "minimum": -180, "maximum": 180 }, "lat": { "type": "number", - "description": "La latitude du point.", + "description": "Latitude du point en WGS84.", "minimum": -90, "maximum": 90 } @@ -426,8 +427,8 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `lat` | number | oui | La latitude du point. | -| `lon` | number | oui | La longitude du point. | +| `lat` | number | oui | Latitude du point en WGS84. | +| `lon` | number | oui | Longitude du point en WGS84. |
Schéma d’entrée brut @@ -438,13 +439,13 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp "properties": { "lon": { "type": "number", - "description": "La longitude du point.", + "description": "Longitude du point en WGS84.", "minimum": -180, "maximum": 180 }, "lat": { "type": "number", - "description": "La latitude du point.", + "description": "Latitude du point en WGS84.", "minimum": -90, "maximum": 90 } @@ -570,8 +571,8 @@ Modèles d'URL Géoportail de l'Urbanisme : | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `lat` | number | oui | La latitude du point. | -| `lon` | number | oui | La longitude du point. | +| `lat` | number | oui | Latitude du point en WGS84. | +| `lon` | number | oui | Longitude du point en WGS84. |
Schéma d’entrée brut @@ -582,13 +583,13 @@ Modèles d'URL Géoportail de l'Urbanisme : "properties": { "lon": { "type": "number", - "description": "La longitude du point.", + "description": "Longitude du point en WGS84.", "minimum": -180, "maximum": 180 }, "lat": { "type": "number", - "description": "La latitude du point.", + "description": "Latitude du point en WGS84.", "minimum": -90, "maximum": 90 } @@ -704,8 +705,8 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `lat` | number | oui | La latitude du point. | -| `lon` | number | oui | La longitude du point. | +| `lat` | number | oui | Latitude du point en WGS84. | +| `lon` | number | oui | Longitude du point en WGS84. |
Schéma d’entrée brut @@ -716,13 +717,13 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp "properties": { "lon": { "type": "number", - "description": "La longitude du point.", + "description": "Longitude du point en WGS84.", "minimum": -180, "maximum": 180 }, "lat": { "type": "number", - "description": "La latitude du point.", + "description": "Latitude du point en WGS84.", "minimum": -90, "maximum": 90 } @@ -1110,7 +1111,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. | | `order_by` | array | non | Liste ordonnée des critères de tri. | | `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. | -| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.
`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter_center` est la distance (en m) entre le centre du filtre spatial et le point le plus proche de l'objet renvoyé, `0` si l'objet contient ce centre. Ce centre est le point de `dwithin_point_filter`, le point de départ de `isoline_filter`, le centre de la boîte de `bbox_filter` et le centroïde (moyenne des sommets) de l'objet de référence de `intersects_feature_filter`.
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance ou objet de référence surfacique). Elle vaut `null` si l'objet renvoyé n'a pas de partie surfacique, et `0` si l'objet ne recouvre pas le filtre.
`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.
Les `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, les N plus proches) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit` ou restreindre le filtre spatial. Pour les N plus proches d'un point, utiliser `dwithin_point_filter` avec `distance_to_filter_center`, trier sur cette distance et élargir `distance_m` s'il y a moins de N objets.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. | +| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.
`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter_center` est la distance (en m) entre le centre du filtre spatial et le point le plus proche de l'objet renvoyé, `0` si l'objet contient ce centre. Ce centre est le point de `dwithin_point_filter`, le point de départ de `isoline_filter`, le centre de la boîte de `bbox_filter` et le centroïde (moyenne des sommets) de l'objet de référence de `intersects_feature_filter`.
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance ou objet de référence surfacique). Elle vaut `null` si l'objet renvoyé n'a pas de partie surfacique, et `0` si l'objet ne recouvre pas le filtre.
`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.
Les `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, les N plus proches) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit` ou restreindre le filtre spatial. Pour les N plus proches d'un point, utiliser `dwithin_point_filter` avec `distance_to_filter_center`, trier sur cette distance et élargir `distance_m` s'il y a moins de N objets.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. | | `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. | | `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. | @@ -1191,25 +1192,25 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude ouest en WGS84 `lon/lat`." + "description": "Longitude ouest en WGS84." }, "south": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude sud en WGS84 `lon/lat`." + "description": "Latitude sud en WGS84." }, "east": { "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude est en WGS84 `lon/lat`." + "description": "Longitude est en WGS84." }, "north": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude nord en WGS84 `lon/lat`." + "description": "Latitude nord en WGS84." } }, "required": [ @@ -1228,13 +1229,13 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`." + "description": "Longitude du point en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`." + "description": "Latitude du point en WGS84." } }, "required": [ @@ -1251,13 +1252,13 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`." + "description": "Longitude du point en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`." + "description": "Latitude du point en WGS84." }, "distance_m": { "type": "number", @@ -1299,15 +1300,15 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "properties": { "lon": { "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude du point de départ en WGS84 `lon/lat`." + "minimum": -63.28125, + "maximum": 55.8984375, + "description": "Longitude du point de départ en WGS84." }, "lat": { "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude du point de départ en WGS84 `lon/lat`." + "minimum": -21.42148437, + "maximum": 51.27109375, + "description": "Latitude du point de départ en WGS84." }, "profile": { "type": "string", @@ -1391,7 +1392,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu ] }, "default": [], - "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.\n`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter_center` est la distance (en m) entre le centre du filtre spatial et le point le plus proche de l'objet renvoyé, `0` si l'objet contient ce centre. Ce centre est le point de `dwithin_point_filter`, le point de départ de `isoline_filter`, le centre de la boîte de `bbox_filter` et le centroïde (moyenne des sommets) de l'objet de référence de `intersects_feature_filter`.\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance ou objet de référence surfacique). Elle vaut `null` si l'objet renvoyé n'a pas de partie surfacique, et `0` si l'objet ne recouvre pas le filtre.\n`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\nLes `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, les N plus proches) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit` ou restreindre le filtre spatial. Pour les N plus proches d'un point, utiliser `dwithin_point_filter` avec `distance_to_filter_center`, trier sur cette distance et élargir `distance_m` s'il y a moins de N objets.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu." + "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.\n`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter_center` est la distance (en m) entre le centre du filtre spatial et le point le plus proche de l'objet renvoyé, `0` si l'objet contient ce centre. Ce centre est le point de `dwithin_point_filter`, le point de départ de `isoline_filter`, le centre de la boîte de `bbox_filter` et le centroïde (moyenne des sommets) de l'objet de référence de `intersects_feature_filter`.\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance ou objet de référence surfacique). Elle vaut `null` si l'objet renvoyé n'a pas de partie surfacique, et `0` si l'objet ne recouvre pas le filtre.\n`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\nLes `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, les N plus proches) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit` ou restreindre le filtre spatial. Pour les N plus proches d'un point, utiliser `dwithin_point_filter` avec `distance_to_filter_center`, trier sur cette distance et élargir `distance_m` s'il y a moins de N objets.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu." } }, "required": [ @@ -1524,25 +1525,25 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude ouest en WGS84 `lon/lat`." + "description": "Longitude ouest en WGS84." }, "south": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude sud en WGS84 `lon/lat`." + "description": "Latitude sud en WGS84." }, "east": { "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude est en WGS84 `lon/lat`." + "description": "Longitude est en WGS84." }, "north": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude nord en WGS84 `lon/lat`." + "description": "Latitude nord en WGS84." } }, "required": [ @@ -1561,13 +1562,13 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`." + "description": "Longitude du point en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`." + "description": "Latitude du point en WGS84." } }, "required": [ @@ -1584,13 +1585,13 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`." + "description": "Longitude du point en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`." + "description": "Latitude du point en WGS84." }, "distance_m": { "type": "number", @@ -1632,15 +1633,15 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "properties": { "lon": { "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude du point de départ en WGS84 `lon/lat`." + "minimum": -63.28125, + "maximum": 55.8984375, + "description": "Longitude du point de départ en WGS84." }, "lat": { "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude du point de départ en WGS84 `lon/lat`." + "minimum": -21.42148437, + "maximum": 51.27109375, + "description": "Latitude du point de départ en WGS84." }, "profile": { "type": "string", @@ -1860,25 +1861,25 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude ouest en WGS84 `lon/lat`." + "description": "Longitude ouest en WGS84." }, "south": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude sud en WGS84 `lon/lat`." + "description": "Latitude sud en WGS84." }, "east": { "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude est en WGS84 `lon/lat`." + "description": "Longitude est en WGS84." }, "north": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude nord en WGS84 `lon/lat`." + "description": "Latitude nord en WGS84." } }, "required": [ @@ -1897,13 +1898,13 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`." + "description": "Longitude du point en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`." + "description": "Latitude du point en WGS84." } }, "required": [ @@ -1920,13 +1921,13 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "type": "number", "minimum": -180, "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`." + "description": "Longitude du point en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`." + "description": "Latitude du point en WGS84." }, "distance_m": { "type": "number", @@ -1968,15 +1969,15 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "properties": { "lon": { "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude du point de départ en WGS84 `lon/lat`." + "minimum": -63.28125, + "maximum": 55.8984375, + "description": "Longitude du point de départ en WGS84." }, "lat": { "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude du point de départ en WGS84 `lon/lat`." + "minimum": -21.42148437, + "maximum": 51.27109375, + "description": "Latitude du point de départ en WGS84." }, "profile": { "type": "string", @@ -2077,7 +2078,7 @@ Utiliser `spatial_extras` pour renvoyer une information géométrique dérivée | --- | --- | --- | --- | | `feature_id` | string | oui | Identifiant GPF exact de l'objet à récupérer, par exemple `commune.8952`. | | `select` | array | non | Liste des propriétés non géométriques à renvoyer. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. | -| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave.
`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. | +| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave.
`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. | | `typename` | string | oui | Nom exact du type GPF à interroger, par exemple `ADMINEXPRESS-COG.LATEST:commune`. |
@@ -2118,7 +2119,7 @@ Utiliser `spatial_extras` pour renvoyer une information géométrique dérivée ] }, "default": [], - "description": "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave.\n`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu." + "description": "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave.\n`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu." } }, "required": [ @@ -2265,8 +2266,8 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac | --- | --- | --- | --- | | `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. | | `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"` (maximum : 50000). | -| `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. | -| `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. | +| `lat` | number | oui | Latitude du point de départ en WGS84. | +| `lon` | number | oui | Longitude du point de départ en WGS84. | | `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. |
@@ -2278,15 +2279,15 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac "properties": { "lon": { "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude du point de départ en WGS84 `lon/lat`." + "minimum": -63.28125, + "maximum": 55.8984375, + "description": "Longitude du point de départ en WGS84." }, "lat": { "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude du point de départ en WGS84 `lon/lat`." + "minimum": -21.42148437, + "maximum": 51.27109375, + "description": "Latitude du point de départ en WGS84." }, "profile": { "type": "string", @@ -2358,6 +2359,151 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac | Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). | +## `gpf_itinerary_layer` + +Code Source : [src/tools/GpfItineraryLayerTool.ts](../src/tools/GpfItineraryLayerTool.ts) + +### Titre + +Couche cartographiable d’itinéraire GPF + +### Description du tool + +``` +Interroge l'itinéraire entre deux points. +À utiliser pour afficher ou cartographier un trajet. Pour obtenir seulement la distance et le temps de trajet, utiliser plutôt l'outil `distance`. +Renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON contenant la géométrie LineString de l'itinéraire, avec ses propriétés `distance` (en mètres) et `time` (en minutes). +Utiliser `departure`/`arrival` pour les points de départ et d'arrivée, `profile` pour le mode de déplacement (`car` ou `pedestrian`) et `optimize` pour choisir entre l'itinéraire le plus rapide (`time`) ou le plus court (`distance`). +Avec `pedestrian`, le départ et l'arrivée doivent être distants d'au plus 200 km à vol d'oiseau. +(source : Géoplateforme (calcul d'itinéraire)). +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `arrival` | object | oui | Le point d'arrivée. | +| `departure` | object | oui | Le point de départ. | +| `optimize` | string (enum) | non | Métrique d'optimisation : `time` (itinéraire le plus rapide) ou `distance` (le plus court). Valeurs : time, distance. Valeur par défaut : time. | +| `profile` | string (enum) | oui | Mode de déplacement : `car` ou `pedestrian`. Valeurs : car, pedestrian. | + +
+Schéma d’entrée brut + +```json +{ + "type": "object", + "properties": { + "departure": { + "type": "object", + "properties": { + "lon": { + "type": "number", + "minimum": -63.28125, + "maximum": 55.8984375, + "description": "Longitude du point de départ en WGS84." + }, + "lat": { + "type": "number", + "minimum": -21.42148437, + "maximum": 51.27109375, + "description": "Latitude du point de départ en WGS84." + } + }, + "required": [ + "lon", + "lat" + ], + "additionalProperties": false, + "description": "Le point de départ." + }, + "arrival": { + "type": "object", + "properties": { + "lon": { + "type": "number", + "minimum": -63.28125, + "maximum": 55.8984375, + "description": "Longitude du point d'arrivée en WGS84." + }, + "lat": { + "type": "number", + "minimum": -21.42148437, + "maximum": 51.27109375, + "description": "Latitude du point d'arrivée en WGS84." + } + }, + "required": [ + "lon", + "lat" + ], + "additionalProperties": false, + "description": "Le point d'arrivée." + }, + "profile": { + "type": "string", + "enum": [ + "car", + "pedestrian" + ], + "description": "Mode de déplacement : `car` ou `pedestrian`." + }, + "optimize": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "default": "time", + "description": "Métrique d'optimisation : `time` (itinéraire le plus rapide) ou `distance` (le plus court)." + } + }, + "required": [ + "departure", + "arrival", + "profile" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} +``` + +
+ +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `data_url` | string | oui | URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "data_url": { + "type": "string", + "description": "URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique.", + "format": "uri" + } + }, + "required": [ + "data_url" + ] +} +``` + +
+ +### Réponse MCP + +| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` | +| --- | --- | --- | --- | +| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | +| Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). | + ## `distance` Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts) @@ -2371,6 +2517,8 @@ 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 lorsque `profile` vaut `car` ou `pedestrian`. +Avec `pedestrian`, le départ et l'arrivée doivent être distants d'au plus 200 km à vol d'oiseau. +Pour obtenir l'itinéraire sous forme de couche cartographiable, utiliser `gpf_itinerary_layer`. (source : Géoplateforme (calcul d'itinéraire)). ``` @@ -2378,8 +2526,8 @@ Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `arrival` | object | oui | Le point d'arrivée | -| `departure` | object | oui | Le point de départ | +| `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, précision à 0.5cm), `car` en voiture, `pedestrian` à pied. Valeurs : spherical, ellipsoidal, car, pedestrian. Valeur par défaut : spherical. | @@ -2397,13 +2545,13 @@ Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou "type": "number", "minimum": -180, "maximum": 180, - "description": "La longitude du point de départ." + "description": "Longitude du point de départ en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "La latitude du point de départ." + "description": "Latitude du point de départ en WGS84." } }, "required": [ @@ -2411,7 +2559,7 @@ Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou "lat" ], "additionalProperties": false, - "description": "Le point de départ" + "description": "Le point de départ." }, "arrival": { "type": "object", @@ -2420,13 +2568,13 @@ Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou "type": "number", "minimum": -180, "maximum": 180, - "description": "La longitude du point d'arrivée." + "description": "Longitude du point d'arrivée en WGS84." }, "lat": { "type": "number", "minimum": -90, "maximum": 90, - "description": "La latitude du point d'arrivée." + "description": "Latitude du point d'arrivée en WGS84." } }, "required": [ @@ -2434,7 +2582,7 @@ Renvoie aussi une estimation du temps de trajet lorsque `profile` vaut `car` ou "lat" ], "additionalProperties": false, - "description": "Le point d'arrivée" + "description": "Le point d'arrivée." }, "profile": { "type": "string", diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index cd9e4afc..096f153c 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -24,6 +24,7 @@ const toolDisplayOrder = [ "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", "gpf_isoline_layer", + "gpf_itinerary_layer", ]; /** diff --git a/src/config/env.ts b/src/config/env.ts index ff016cb2..8013f22c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -168,6 +168,9 @@ const envSchema = z.object({ GPF_WFS_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(30)), GPF_GEOCODE_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(50)), GPF_ALTI_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(50)), + // Budget for MCP calls to the GPF navigation service, shared through a single + // RateLimiter instance (see getNavigationRateLimiter). The proxy draws on + // GPF_NAVIGATION_PROXY_RATE_LIMIT instead. GPF_NAVIGATION_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(5)), // GPF GPF_WFS_MINISEARCH_OPTIONS: z diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index 8b704a1a..df874110 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -1,9 +1,11 @@ -import { fetchJSONGet } from "../helpers/http.js"; +import { fetchJSONGet, ServiceResponseError } 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 { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE } from "./navigation.js"; +import type { LineString } from "geojson"; +import { isGeometryLike } from "../helpers/geojson.js"; export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)"; export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire"; @@ -15,11 +17,20 @@ export const ITINERARY_METRICS = NAVIGATION_METRICS; export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; export type ItineraryMetric = typeof ITINERARY_METRICS[number]; +/** + * Maximum crow-flies distance accepted between departure and arrival with `pedestrian`, + * under the upstream's own limit (~250 km, beyond which it answers "No path found"). + * `car` has no upstream limit. + */ +export const ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS = 200_000; + type ItineraryResponse = { distance: number; duration: number; }; +export type ItineraryLayerResponse = ItineraryResponse & { geometry: LineString; }; + export type ItineraryInput = { departure: { lon: number; @@ -57,15 +68,32 @@ function buildItineraryUrl(input: ItineraryInput, geometryFormat: "polyline" | " */ 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."); + throw new ServiceResponseError( + "Le service d'itinéraire n'a pas renvoyé de distance et de durée exploitables.", + { + http: { status: 502, statusText: "Bad Gateway" }, + service: { code: "invalid_upstream_body", detail: "distance/duration manquantes ou non numériques" }, + }, + ); } return { distance, duration }; } +/** + * Rounds the itinerary distance (to the cm) and duration (to the tenth of a minute), + * so every tool reports identical figures. + */ +export function roundItineraryCosts({ distance, duration }: ItineraryResponse) { + return { + distance: Math.round(distance * 100) / 100, + time: Math.round(duration * 10) / 10, + }; +} + export class NavigationItineraryClient { constructor( private rateLimiter: RateLimiter, - private fetcher: JsonFetcher<{distance?: unknown; duration?: unknown}> = fetchJSONGet, + private fetcher: JsonFetcher<{distance?: unknown; duration?: unknown; geometry?: unknown}> = fetchJSONGet, ) {} async getItinerary(input: ItineraryInput): Promise { @@ -76,6 +104,30 @@ export class NavigationItineraryClient { const result = await this.fetcher(buildItineraryUrl(input, "polyline")); return parseItineraryCosts(result.distance, result.duration); } + + /** + * Requests the route with `geometryFormat: "geojson"` so the response includes the route geometry + * (a LineString) alongside distance and duration. + */ + async getItineraryLayer(input: ItineraryInput): Promise { + await this.rateLimiter.limit(); + logger.debug(`[gpf:navigation] getItineraryLayer(${JSON.stringify(input)})...`); + + const result = await this.fetcher(buildItineraryUrl(input, "geojson")); + if (!(isGeometryLike(result.geometry) && result.geometry.type === "LineString" && result.geometry.coordinates.length >= 2)) { + throw new ServiceResponseError( + "Le service d'itinéraire n'a pas renvoyé de LineString exploitable.", + { + http: { status: 502, statusText: "Bad Gateway" }, + service: { code: "invalid_upstream_body", detail: "geometry manquante ou non-LineString" }, + }, + ); + } + return { + geometry: result.geometry, + ...parseItineraryCosts(result.distance, result.duration), + }; + } } let defaultNavigationItineraryClient: NavigationItineraryClient | undefined; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index b0b6b499..04303664 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -12,6 +12,9 @@ export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla"; // Upstream ceilings accepted by the GPF isochrone service, per cost type. export const NAVIGATION_ISOCHRONE_MAX_MINUTES = 600; export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000; +// Upstream extent accepted for route and isochrone points, as `[west, south, east, north]` +// in WGS84 (from the service's GetCapabilities for bdtopo-valhalla). +export const NAVIGATION_BBOX = [-63.28125, -21.42148437, 55.8984375, 51.27109375] as const; export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const NAVIGATION_METRICS = ["time", "distance"] as const; diff --git a/src/helpers/geojson.ts b/src/helpers/geojson.ts index e8376800..bd1fba28 100644 --- a/src/helpers/geojson.ts +++ b/src/helpers/geojson.ts @@ -12,7 +12,8 @@ export function isGeometryLike(value: unknown): value is Exclude Promise; + +export type GeometryItineraryQueryDeps = { + getItineraryLayer: ItineraryLayerResolver; +}; + +/** + * Fetches the itinerary route and returns it as a GeoJSON `FeatureCollection` + * with full geometry (for map rendering by MCP Carto). + * + * The request params and computed distance/duration are echoed into `properties` + * so the rendered layer carries its own legend. + * + * @param input Validated itinerary layer input. + * @param deps Injected itinerary geometry resolver. + * @returns The route as a GeoJSON FeatureCollection. + */ +export async function runGeometryItineraryQuery( + input: GpfItineraryLayerInput, + deps: GeometryItineraryQueryDeps, +): Promise { + const { geometry, ...costs } = await deps.getItineraryLayer(input); + + return { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + geometry, + properties: { + profile: input.profile, + optimize: input.optimize, + departure: [input.departure.lon, input.departure.lat], + arrival: [input.arrival.lon, input.arrival.lat], + // Same rounding as the `distance` tool, so both report identical figures. + ...roundItineraryCosts(costs), + }, + }, + ] + }; +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 0af7d89d..ba6ee97f 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -15,18 +15,21 @@ import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, gpfIsolineLayerInputSchema, + gpfItineraryLayerInputSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, + runGeometryItineraryQuery, } from "./execute.js"; import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js"; import { getDefaultGeometryFeatureQueryDeps, getDefaultGeometryFeatureByIdQueryDeps, getDefaultGeometryIsolineQueryDeps, + getDefaultGeometryItineraryQueryDeps, } from "./transport.js"; import { decodeToken, @@ -192,6 +195,12 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise fetchJSONGetWithLimit(url, getEnv().PROXY_UPSTREAM_TIMEOUT * 1000, getEnv().PROXY_MAX_RESPONSE_BYTES, "d'isochrone"), ); return cachedProxyIsolineClient; @@ -165,3 +183,31 @@ export function getDefaultGeometryIsolineQueryDeps(): GeometryIsolineQueryDeps { getGeometry: (input) => getProxyIsolineClient().getIsoline(input), }; } + +// --- Proxy Itinerary Client (singleton) --- + +let cachedProxyItineraryClient: NavigationItineraryClient | undefined; + +/** + * Returns the proxy itinerary client: a dedicated `NavigationItineraryClient` + * wired to the size-bounded, shorter-timeout fetch the geodata proxy leg uses and the + * `GPF_NAVIGATION_PROXY` rate limiter it shares with the proxy isoline client. + * Lazily built so the bounds are read from a fully-parsed environment. + */ +function getProxyItineraryClient(): NavigationItineraryClient { + cachedProxyItineraryClient ??= new NavigationItineraryClient( + getProxyNavigationRateLimiter(), + (url) => fetchJSONGetWithLimit(url, getEnv().PROXY_UPSTREAM_TIMEOUT * 1000, getEnv().PROXY_MAX_RESPONSE_BYTES, "d'itinéraire"), + ); + return cachedProxyItineraryClient; +} + +/** + * Default dependency bundle for `runGeometryItineraryQuery`. + */ +export function getDefaultGeometryItineraryQueryDeps(): GeometryItineraryQueryDeps { + return { + getItineraryLayer: (input) => + getProxyItineraryClient().getItineraryLayer(input), + }; +} diff --git a/src/tools/AdminexpressTool.ts b/src/tools/AdminexpressTool.ts index d729fa33..a2a0e3e3 100644 --- a/src/tools/AdminexpressTool.ts +++ b/src/tools/AdminexpressTool.ts @@ -7,15 +7,12 @@ import { z } from "zod"; import { getAdminUnits, ADMINEXPRESS_TYPES, ADMINEXPRESS_SOURCE } from "../gpf/adminexpress.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; +import { featureRefSchema, buildLonLatSchema } from "../helpers/schemas.js"; import logger from "../logger.js"; // --- Schemas --- -const adminexpressInputSchema = z.object({ - lon: lonSchema, - lat: latSchema, -}).strict(); +const adminexpressInputSchema = buildLonLatSchema(); const adminexpressResultSchema = z .object({ diff --git a/src/tools/AltitudeTool.ts b/src/tools/AltitudeTool.ts index a28016da..f983978d 100644 --- a/src/tools/AltitudeTool.ts +++ b/src/tools/AltitudeTool.ts @@ -7,15 +7,12 @@ import { z } from "zod"; import { ALTITUDE_SOURCE, altitudeClient } from "../gpf/altitude.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { lonSchema, latSchema } from "../helpers/schemas.js"; +import { buildLonLatSchema } from "../helpers/schemas.js"; import logger from "../logger.js"; // --- Schemas --- -const altitudeInputSchema = z.object({ - lon: lonSchema, - lat: latSchema, -}).strict(); +const altitudeInputSchema = buildLonLatSchema(); const altitudeOutputSchema = z.object({ lon: z.number().describe("La longitude du point."), diff --git a/src/tools/AssietteSupTool.ts b/src/tools/AssietteSupTool.ts index 0102b66b..34e32378 100644 --- a/src/tools/AssietteSupTool.ts +++ b/src/tools/AssietteSupTool.ts @@ -7,15 +7,12 @@ import { z } from "zod"; import { getAssiettesServitudes, URBANISME_SOURCE } from "../gpf/urbanisme.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; +import { featureRefSchema, buildLonLatSchema } from "../helpers/schemas.js"; import logger from "../logger.js"; // --- Schemas --- -const assietteSupInputSchema = z.object({ - lon: lonSchema, - lat: latSchema, -}).strict(); +const assietteSupInputSchema = buildLonLatSchema(); const assietteSupResultSchema = z .object({ diff --git a/src/tools/CadastreTool.ts b/src/tools/CadastreTool.ts index 85f00447..d0f86a94 100644 --- a/src/tools/CadastreTool.ts +++ b/src/tools/CadastreTool.ts @@ -7,15 +7,12 @@ import { z } from "zod"; import { getParcellaireExpress, PARCELLAIRE_EXPRESS_TYPES, PARCELLAIRE_EXPRESS_SOURCE } from "../gpf/parcellaire-express.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; +import { featureRefSchema, buildLonLatSchema } from "../helpers/schemas.js"; import logger from "../logger.js"; // --- Schemas --- -const cadastreInputSchema = z.object({ - lon: lonSchema, - lat: latSchema, -}).strict(); +const cadastreInputSchema = buildLonLatSchema(); const cadastreResultSchema = z .object({ diff --git a/src/tools/DistanceTool.ts b/src/tools/DistanceTool.ts index d21cc849..aa0c91ba 100644 --- a/src/tools/DistanceTool.ts +++ b/src/tools/DistanceTool.ts @@ -5,24 +5,19 @@ import BaseTool from "./BaseTool.js"; import { z } from "zod"; -import { NAVIGATION_ITINERARY_SOURCE, navigationItineraryClient, ITINERARY_METRICS, ITINERARY_PROFILES } from "../gpf/itinerary.js"; +import { NAVIGATION_ITINERARY_SOURCE, navigationItineraryClient, roundItineraryCosts, ITINERARY_METRICS, ITINERARY_PROFILES, ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS } from "../gpf/itinerary.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { lonSchema, latSchema } from "../helpers/schemas.js"; +import { buildLonLatSchema } from "../helpers/schemas.js"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; +import { gpfItineraryLayerInputSchema } from "../wfs/schema.js"; import logger from "../logger.js"; import { ellipsoidalDistance, haversine } from "../helpers/distance.js"; // --- Schemas --- const distanceInputSchema = z.object({ - departure: z.object({ - lon: lonSchema.describe("La longitude du point de départ."), - lat: latSchema.describe("La latitude du point de départ."), - }).describe("Le point de départ"), - arrival: z.object({ - lon: lonSchema.describe("La longitude du point d'arrivée."), - lat: latSchema.describe("La latitude du point d'arrivée."), - }).describe("Le point d'arrivée"), + departure: buildLonLatSchema("de départ"), + arrival: buildLonLatSchema("d'arrivée"), profile: z .enum(["spherical", "ellipsoidal", ...ITINERARY_PROFILES]) .default("spherical") @@ -56,6 +51,8 @@ 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 lorsque \`profile\` vaut \`car\` ou \`pedestrian\`.`, + `Avec \`pedestrian\`, le départ et l'arrivée doivent être distants d'au plus ${ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS / 1000} km à vol d'oiseau.`, + `Pour obtenir l'itinéraire sous forme de couche cartographiable, utiliser \`gpf_itinerary_layer\`.`, `(source : ${NAVIGATION_ITINERARY_SOURCE}).`, ].join("\n"); @@ -99,16 +96,10 @@ class DistanceTool extends BaseTool { } 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 - }; + // Same upstream as `gpf_itinerary_layer`, so the same extent and pedestrian cap apply. + const itineraryInput = gpfItineraryLayerInputSchema.parse(input); + const itinerary = await navigationItineraryClient.getItinerary(itineraryInput); + return roundItineraryCosts(itinerary); } default: { const profile: never = input.profile; diff --git a/src/tools/GpfItineraryLayerTool.ts b/src/tools/GpfItineraryLayerTool.ts new file mode 100644 index 00000000..e6592770 --- /dev/null +++ b/src/tools/GpfItineraryLayerTool.ts @@ -0,0 +1,110 @@ +/** + * MCP tool producing an opaque, cartographiable layer URL for a Géoplateforme + * itinerary request. + * + * The tool returns a short opaque `data_url` that the LLM passes verbatim to a + * map client. Fetching it yields a GeoJSON FeatureCollection (a single LineString + * feature) served by the stateless geodata proxy. The URL encodes the validated + * request params as an opaque token, so the LLM can neither parse nor rebuild the + * underlying upstream request. + */ + +import BaseTool from "./BaseTool.js"; + +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { getEnv } from "../config/env.js"; +import { encodeToken } from "../proxy/token.js"; +import { buildDataUrl } from "../proxy/dataUrl.js"; +import { + PROXY_TOKEN_KIND, + gpfGetFeaturesLayerOutputSchema, + gpfItineraryLayerInputObjectSchema, + gpfItineraryLayerInputSchema, + gpfItineraryLayerPublishedInputSchema, + type GpfItineraryLayerInput, +} from "../wfs/schema.js"; +import { ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS, NAVIGATION_ITINERARY_SOURCE } from "../gpf/itinerary.js"; +import logger from "../logger.js"; + +const GPF_ITINERARY_LAYER_TOOL_DESCRIPTION = [ + "Interroge l'itinéraire entre deux points.", + "À utiliser pour afficher ou cartographier un trajet. Pour obtenir seulement la distance et le temps de trajet, utiliser plutôt l'outil `distance`.", + "Renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON contenant la géométrie LineString de l'itinéraire, avec ses propriétés `distance` (en mètres) et `time` (en minutes).", + "Utiliser `departure`/`arrival` pour les points de départ et d'arrivée, `profile` pour le mode de déplacement (`car` ou `pedestrian`) et `optimize` pour choisir entre l'itinéraire le plus rapide (`time`) ou le plus court (`distance`).", + `Avec \`pedestrian\`, le départ et l'arrivée doivent être distants d'au plus ${ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS / 1000} km à vol d'oiseau.`, + `(source : ${NAVIGATION_ITINERARY_SOURCE}).`, +].join("\n"); + +// --- Tool --- + +class GpfItineraryLayerTool extends BaseTool { + name = "gpf_itinerary_layer"; + title = "Couche cartographiable d’itinéraire GPF"; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; + description = GPF_ITINERARY_LAYER_TOOL_DESCRIPTION; + protected outputSchemaShape = gpfGetFeaturesLayerOutputSchema; + + // The framework requires a plain Zod object here to publish a compatible input + // schema. `execute` re-parses through `gpfItineraryLayerInputSchema` so the + // crow-flies distance cap still runs before a token is minted. + schema = gpfItineraryLayerInputObjectSchema; + + /** + * Exposes an input schema variant that stays compatible with most MCP integrations. + * + * @returns The published input schema exposed through the MCP tool definition. + */ + get inputSchema() { + return gpfItineraryLayerPublishedInputSchema; + } + + /** + * Formats the `{ data_url }` response into `structuredContent`. + * + * @param data Raw execution result returned by the tool implementation. + * @returns An MCP success response enriched with structured content. + */ + protected createSuccessResponse(data: unknown) { + const payload = gpfGetFeaturesLayerOutputSchema.parse(data); + + return { + content: [{ type: "text" as const, text: JSON.stringify(payload) }], + structuredContent: payload, + }; + } + + /** + * Mints the opaque proxy URL for the requested itinerary. No upstream call is + * made here: the route itself is computed by the proxy when the `data_url` + * is fetched. + * + * @param input Validated itinerary layer input. + * @returns The `{ data_url }` payload carrying the opaque token. + */ + async execute(input: GpfItineraryLayerInput) { + const env = getEnv(); + + if (!env.PROXY_URL_SECRET || !env.PROXY_PUBLIC_BASE_URL) { + throw new Error( + "`gpf_itinerary_layer` nécessite un proxy geodata configuré (variables d'environnement `PROXY_URL_SECRET` et `PROXY_PUBLIC_BASE_URL`, pointant vers un proxy joignable).", + ); + } + + const tokenParams = gpfItineraryLayerInputSchema.parse(input); + + logger.info(`[tool] execute ${this.name} ...`, { + input: tokenParams, + }); + + const token = encodeToken( + { kind: PROXY_TOKEN_KIND.itinerary, ...tokenParams }, + env.PROXY_URL_SECRET, + ); + + const dataUrl = buildDataUrl(env.PROXY_PUBLIC_BASE_URL, env.PROXY_ENDPOINT, token); + + return { data_url: dataUrl }; + } +} + +export default GpfItineraryLayerTool; diff --git a/src/tools/UrbanismeTool.ts b/src/tools/UrbanismeTool.ts index c0fb002f..a492ea37 100644 --- a/src/tools/UrbanismeTool.ts +++ b/src/tools/UrbanismeTool.ts @@ -7,15 +7,12 @@ import { z } from "zod"; import { getUrbanisme, URBANISME_SOURCE } from "../gpf/urbanisme.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; +import { featureRefSchema, buildLonLatSchema } from "../helpers/schemas.js"; import logger from "../logger.js"; // --- Schemas --- -const urbanismeInputSchema = z.object({ - lon: lonSchema, - lat: latSchema, -}).strict(); +const urbanismeInputSchema = buildLonLatSchema(); const urbanismeResultSchema = z .object({ diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 31058106..7eb46e89 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -9,14 +9,21 @@ import { z } from "zod"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; -import { lonSchema, latSchema } from "../helpers/schemas.js"; +import { lonSchema, latSchema, buildLonLatSchema } from "../helpers/schemas.js"; import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS, + NAVIGATION_BBOX, type NavigationMetric, } from "../gpf/navigation.js"; +import { + ITINERARY_METRICS, + ITINERARY_PROFILES, + ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS, +} from "../gpf/itinerary.js"; +import { haversine } from "../helpers/distance.js"; // --- Shared Constants --- @@ -75,22 +82,18 @@ const orderBySchema = z.object({ }).strict().describe("Critère de tri structuré. Exemple : `{ property: \"population\", direction: \"desc\" }`."); const bboxFilterSchema = z.object({ - west: lonSchema.describe("Longitude ouest en WGS84 `lon/lat`."), - south: latSchema.describe("Latitude sud en WGS84 `lon/lat`."), - east: lonSchema.describe("Longitude est en WGS84 `lon/lat`."), - north: latSchema.describe("Latitude nord en WGS84 `lon/lat`."), + west: lonSchema.describe("Longitude ouest en WGS84."), + south: latSchema.describe("Latitude sud en WGS84."), + east: lonSchema.describe("Longitude est en WGS84."), + north: latSchema.describe("Latitude nord en WGS84."), }).strict().describe("Filtre spatial par boîte englobante."); -const intersectsPointFilterSchema = z.object({ - lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`."), - lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`."), -}).strict().describe("Filtre les objets dont la géométrie intersecte un point."); +const intersectsPointFilterSchema = buildLonLatSchema() + .describe("Filtre les objets dont la géométrie intersecte un point."); -const dwithinPointFilterSchema = z.object({ - lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`."), - lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`."), +const dwithinPointFilterSchema = buildLonLatSchema().extend({ distance_m: z.number().finite().positive().describe("Distance maximale en mètres."), -}).strict().describe("Filtre les objets situés à une distance maximale d'un point."); +}).describe("Filtre les objets situés à une distance maximale d'un point."); const intersectsFeatureFilterSchema = z.object({ typename: z.string().trim().min(1).describe("Type GPF du feature de référence."), @@ -101,13 +104,20 @@ const navigationProfileSchema = z .enum(NAVIGATION_PROFILES) .describe("Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."); +function buildNavigationLonLatSchema(suffix: string) { + const base = buildLonLatSchema(suffix); + const [west, south, east, north] = NAVIGATION_BBOX; + const message = `Le point est hors de l'emprise du service de navigation ([ouest, sud, est, nord] = [${NAVIGATION_BBOX.join(", ")}]).`; + return base.extend({ + lon: base.shape.lon.min(west, message).max(east, message), + lat: base.shape.lat.min(south, message).max(north, message), + }); +} + // Departure point of an isoline. Flat `lon`/`lat`, exactly like every spatial // filter (`intersects_point_filter`, `dwithin_point_filter`, ...), so the LLM sees // one point convention across the whole surface. -const isolinePointSchema = z.object({ - lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."), - lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), -}).strict(); +const isolinePointSchema = buildNavigationLonLatSchema("de départ"); const navigationMetricsSchema = z .enum(NAVIGATION_METRICS) @@ -218,7 +228,7 @@ function spatialExtrasBaseDescriptionLines(withSpatialFilters: boolean) { return [ "`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave" + (withSpatialFilters ? " : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient." : "."), - "`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`" + + "`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84" + (withSpatialFilters ? ", dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`." : "."), "`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).", "`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).", @@ -445,8 +455,8 @@ export const gpfGetFeaturesLayerOutputSchema = z.object({ // --- Proxy token discriminant --- // The proxy serves ONE opaque token (in the URL path, `${endpoint}/.json`) -// but several token kinds (a filtered layer query, a single-feature by-id lookup -// and an isoline). Every producer tool stamps its token +// but several token kinds (a filtered layer query, a single-feature by-id lookup, +// an isoline and an itinerary). Every producer tool stamps its token // with this `kind` discriminant; the proxy reads it to dispatch to the right // schema + engine, then strips it before the strict per-kind `.parse`. It is // injected by the tool from validated params — never an LLM-supplied field. @@ -454,6 +464,7 @@ export const PROXY_TOKEN_KIND = { query: "query", byId: "by_id", isoline: "isoline", + itinerary: "itinerary", } as const; export type ProxyTokenKind = (typeof PROXY_TOKEN_KIND)[keyof typeof PROXY_TOKEN_KIND]; @@ -504,6 +515,58 @@ export type GpfIsolineLayerInput = z.infer; export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema); +// --- `gpf_itinerary_layer` (proxy) --- + +const itineraryProfileSchema = z + .enum(ITINERARY_PROFILES) + .describe("Mode de déplacement : `car` ou `pedestrian`."); + +export const gpfItineraryLayerInputObjectSchema = z.object({ + departure: buildNavigationLonLatSchema("de départ"), + arrival: buildNavigationLonLatSchema("d'arrivée"), + profile: itineraryProfileSchema, + optimize: z + .enum(ITINERARY_METRICS) + .default("time") + .describe("Métrique d'optimisation : `time` (itinéraire le plus rapide) ou `distance` (le plus court)."), +}).strict(); + +/** + * Caps the crow-flies span of a `pedestrian` itinerary request. + * + * The issue is attached to the object root, not to a single coordinate: the constraint + * is a property of the departure/arrival pair. + */ +function assertItineraryDirectDistance( + input: z.infer, + ctx: z.RefinementCtx, +) { + if (input.profile !== "pedestrian") { + return; + } + + const dist = haversine([input.departure.lon, input.departure.lat], [input.arrival.lon, input.arrival.lat]); + + if (dist > ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS, + type: "number", + inclusive: true, + message: `La distance à vol d'oiseau entre le départ et l'arrivée (${Math.ceil(dist / 1000)} km) ne peut pas dépasser ${ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS / 1000} km avec le profil \`pedestrian\`.`, + }); + } +} + +// Refined counterpart of the object schema above, mirroring `gpf_isoline_layer`: the +// published schema stays a plain object while the cross-field cap runs on parse. +export const gpfItineraryLayerInputSchema = gpfItineraryLayerInputObjectSchema + .superRefine(assertItineraryDirectDistance); + +export type GpfItineraryLayerInput = z.infer; + +export const gpfItineraryLayerPublishedInputSchema = generatePublishedInputSchema(gpfItineraryLayerInputObjectSchema); + // --- `gpf_count_features` --- export const gpfCountFeaturesInputObjectSchema = gpfTypenameInputSchema diff --git a/test/gpf/itinerary.test.ts b/test/gpf/itinerary.test.ts index a9b552db..41860be5 100644 --- a/test/gpf/itinerary.test.ts +++ b/test/gpf/itinerary.test.ts @@ -4,18 +4,30 @@ 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( + + const ROUTE_GEOMETRY = { + type: "LineString", + coordinates: [[3.274356, 49.839862], [3.623693, 49.564267]], + }; + + type RawResponse = { geometry?: unknown; distance?: unknown; duration?: unknown }; + + function buildClient(fetcher: (url: string) => Promise) { + return new NavigationItineraryClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), - async (url) => { - urls.push(url); - return { - distance: 395174, - duration: 212, - }; - }, + fetcher, ); + } + + it("should build an itinerary request and return distance and duration", async () => { + const urls: string[] = []; + const client = buildClient(async (url) => { + urls.push(url); + return { + distance: 395174, + duration: 212, + }; + }); const itinerary = await client.getItinerary({ departure: { // 117 rue de Paris, 02100 Saint-Quentin @@ -49,15 +61,50 @@ describe("NavigationItineraryClient", () => { expect(parsedUrl.searchParams.get("getBbox")).toEqual("false"); }); + it("should request a GeoJSON geometry and return it with distance and duration", async () => { + const urls: string[] = []; + const client = buildClient(async (url) => { + urls.push(url); + return { geometry: ROUTE_GEOMETRY, distance: 48231, duration: 42 }; + }); + + const result = await client.getItineraryLayer({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 3.623693, lat: 49.564267 }, + profile: "car", + }); + + expect(result).toEqual({ + geometry: ROUTE_GEOMETRY, + distance: 48231, + duration: 42, + }); + + 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-valhalla"); + expect(parsedUrl.searchParams.get("start")).toEqual("3.274356,49.839862"); + expect(parsedUrl.searchParams.get("end")).toEqual("3.623693,49.564267"); + + 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"); + + // The layer client needs the geometry itself, unlike the plain client. + expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("geojson"); + 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 }; - }, - ); + const client = buildClient(async (url) => { + urls.push(url); + return { distance: 1000, duration: 10 }; + }); await client.getItinerary({ departure: { lon: 3.274356, lat: 49.839862 }, @@ -70,10 +117,7 @@ describe("NavigationItineraryClient", () => { }); 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 }), - ); + const client = buildClient(async () => ({ distance: 1000 })); await expect(client.getItinerary({ departure: { lon: 3.274356, lat: 49.839862 }, @@ -81,4 +125,14 @@ describe("NavigationItineraryClient", () => { profile: "car", })).rejects.toThrow("distance et de durée exploitables"); }); + + it("should reject responses without an exploitable GeoJSON geometry", async () => { + const client = buildClient(async () => ({ distance: 48231, duration: 42 })); + + await expect(client.getItineraryLayer({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 3.623693, lat: 49.564267 }, + profile: "car", + })).rejects.toThrow(/n'a pas renvoyé de LineString/); + }); }); diff --git a/test/integration/samples.ts b/test/integration/samples.ts index fa89d23a..10f732db 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -26,6 +26,7 @@ export const EXPECTED_TOOL_NAMES = [ "gpf_get_feature_by_id", "gpf_count_features", "gpf_isoline_layer", + "gpf_itinerary_layer", "gpf_get_features_layer", "gpf_get_feature_by_id_layer", ] as const; diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts index bfc69de7..7fda4ad3 100644 --- a/test/proxy/execute.test.ts +++ b/test/proxy/execute.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; import type { GpfFeatureType } from "../../src/wfs/catalog.js"; -import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, type WfsClientLike, type IsolineResolver } from "../../src/proxy/execute"; +import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, runGeometryItineraryQuery, type WfsClientLike, type IsolineResolver } from "../../src/proxy/execute"; import type { CompiledRequest } from "../../src/wfs/request"; import type { WfsFeatureCollectionResponse } from "../../src/wfs/types"; import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; @@ -437,3 +437,61 @@ describe("proxy/execute · runGeometryIsolineQuery", () => { }); }); }); + +describe("proxy/execute · runGeometryItineraryQuery", () => { + const itineraryInput = { + departure: { lon: 2.33, lat: 48.84, }, + arrival: { lon: 2.35, lat: 48.85, }, + optimize: "time" as const, + profile: "car" as const, + }; + const routeGeometry = { type: "LineString" as const, coordinates: [[2.33, 48.84], [2.35, 48.85]] }; + + it("returns the itinerary as a FeatureCollection", async () => { + const result = await runGeometryItineraryQuery(itineraryInput, { + getItineraryLayer: async () => ({ geometry: routeGeometry, distance: 3200, duration: 5.5 }), + }); + + expect(result).toEqual({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: routeGeometry, + properties: { + departure: [2.33, 48.84], + arrival: [2.35, 48.85], + profile: "car", + optimize: "time", + distance: 3200, + time: 5.5, + }, + } + ] + }); + }); + + it("rounds distance and duration like the `distance` tool", async () => { + const result = await runGeometryItineraryQuery(itineraryInput, { + getItineraryLayer: async () => ({ geometry: routeGeometry, distance: 3200.4567, duration: 5.5432 }), + }); + + expect(result.features?.[0]?.properties).toMatchObject({ + distance: 3200.46, + time: 5.5, + }); + }); + + it("forwards the input to the resolver", async () => { + const calls: unknown[] = []; + + await runGeometryItineraryQuery(itineraryInput, { + getItineraryLayer: async (input) => { + calls.push(input); + return { geometry: routeGeometry, distance: 3200, duration: 5.5 }; + }, + }); + + expect(calls).toEqual([itineraryInput]); + }); +}); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 3a127bc2..7ec84c96 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -14,15 +14,18 @@ import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } f const runGeometryFeatureQuery = vi.fn(); const runGeometryFeatureByIdQuery = vi.fn(); const runGeometryIsolineQuery = vi.fn(); +const runGeometryItineraryQuery = vi.fn(); vi.mock("../../src/proxy/execute", () => ({ runGeometryFeatureQuery: (...args: unknown[]) => runGeometryFeatureQuery(...args), runGeometryFeatureByIdQuery: (...args: unknown[]) => runGeometryFeatureByIdQuery(...args), runGeometryIsolineQuery: (...args: unknown[]) => runGeometryIsolineQuery(...args), + runGeometryItineraryQuery: (...args: unknown[]) => runGeometryItineraryQuery(...args), })); vi.mock("../../src/proxy/transport", () => ({ getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveIsoline: vi.fn() }), getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }), getDefaultGeometryIsolineQueryDeps: () => ({ getGeometry: vi.fn() }), + getDefaultGeometryItineraryQueryDeps: () => ({ getItineraryLayer: vi.fn() }), })); // A fixed 32-byte hex key for the test environment. @@ -65,6 +68,16 @@ function validIsolineToken() { }, KEY); } +function validItineraryToken() { + return encodeToken({ + kind: PROXY_TOKEN_KIND.itinerary, + departure: { lon: 2.33, lat: 48.84, }, + arrival: { lon: 2.35, lat: 48.85, }, + optimize: "time", + profile: "car", + }, KEY); +} + beforeAll(async () => { process.env.TRANSPORT_TYPE = "http"; process.env.PROXY_URL_SECRET = TEST_SECRET; @@ -91,6 +104,7 @@ beforeEach(() => { runGeometryFeatureQuery.mockReset(); runGeometryFeatureByIdQuery.mockReset(); runGeometryIsolineQuery.mockReset(); + runGeometryItineraryQuery.mockReset(); }); describe("proxy/server", () => { @@ -272,6 +286,44 @@ describe("proxy/server", () => { expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); }); + it("dispatches an itinerary token to the itinerary engine", async () => { + runGeometryItineraryQuery.mockResolvedValue(SAMPLE_COLLECTION); + + const res = await request(baseUrl).get(layerPath(validItineraryToken())); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/geo+json"); + expect(JSON.parse(res.text)).toEqual(SAMPLE_COLLECTION); + expect(runGeometryItineraryQuery).toHaveBeenCalledOnce(); + expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); + expect(runGeometryFeatureByIdQuery).not.toHaveBeenCalled(); + const [input] = runGeometryItineraryQuery.mock.calls[0]; + expect(input).toEqual({ + departure: { lon: 2.33, lat: 48.84, }, + arrival: { lon: 2.35, lat: 48.85, }, + optimize: "time", + profile: "car", + }); + }); + + it("400 on an itinerary token beyond the crow-flies cap", async () => { + // Defense in depth: the tool refuses these at mint time, but a token forged with + // a leaked secret must not reach the upstream itinerary service either. + const overCap = encodeToken({ + kind: PROXY_TOKEN_KIND.itinerary, + // Saint-Quentin -> Dijon: ~300 km apart, over the pedestrian cap. + departure: { lon: 3.274356, lat: 49.839862, }, + arrival: { lon: 5.044572, lat: 47.326213, }, + optimize: "distance", + profile: "pedestrian", + }, KEY); + + const res = await request(baseUrl).get(layerPath(overCap)); + + expect(res.status).toBe(400); + expect(runGeometryItineraryQuery).not.toHaveBeenCalled(); + }); + it("404 when the by-id feature is absent (FeatureNotFoundError)", async () => { runGeometryFeatureByIdQuery.mockRejectedValue( new FeatureNotFoundError("Le feature 'batiment.404' est introuvable dans 'BDTOPO_V3:batiment'."), diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index a3a9c2ae..5638ae70 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -5,19 +5,22 @@ import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; // Mock ONLY the I/O boundaries, so the real proxy transport code runs: // - fetchJSONPostWithLimit (the bounded WFS fetch, parses to JSON) — but keep the real error classes; -// - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the isoline leg -// goes through the SAME PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES bounds as WFS. -// The real NavigationIsolineClient runs (only its fetcher is mocked), so this covers -// the previously-untested gap where the isoline leg used unbounded fetchJSONGet. -// - RateLimiter (assert it is invoked, without real timing). +// - fetchJSONGetWithLimit (the bounded navigation fetch) — asserts the isoline and +// itinerary legs go through the SAME PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES +// bounds as WFS. The real NavigationIsolineClient and NavigationItineraryClient run +// (only their fetcher is mocked), so this covers the previously-untested gap where +// the isoline leg used unbounded fetchJSONGet. +// - RateLimiter (assert it is invoked, without real timing, and record the name of +// every limiter built). // The parse + 502-on-bad-body now lives inside fetchJSON*WithLimit (helpers/http), // so it is covered there; here we only assert the transport wires the right args. // All spies live in `vi.hoisted` because the vi.mock factories are hoisted above // them AND the mocked modules are imported (and RateLimiter constructed) very early. -const { fetchJSONPostWithLimit, fetchJSONGetWithLimit, rateLimit } = vi.hoisted(() => ({ +const { fetchJSONPostWithLimit, fetchJSONGetWithLimit, rateLimit, rateLimiterNames } = vi.hoisted(() => ({ fetchJSONPostWithLimit: vi.fn(), fetchJSONGetWithLimit: vi.fn(), rateLimit: vi.fn(async () => {}), + rateLimiterNames: [] as string[], })); vi.mock("../../src/helpers/http", async (importOriginal) => { @@ -32,11 +35,15 @@ vi.mock("../../src/helpers/http", async (importOriginal) => { vi.mock("../../src/helpers/RateLimiter", () => ({ RateLimiter: class { limit = rateLimit; + constructor({ name }: { name: string }) { + rateLimiterNames.push(name); + } }, })); import { getDefaultGeometryIsolineQueryDeps, + getDefaultGeometryItineraryQueryDeps, getProxyWfsClient, resolveProxyIsolineGeometry, } from "../../src/proxy/transport"; @@ -181,3 +188,49 @@ describe("proxy/transport · getDefaultGeometryIsolineQueryDeps", () => { expect(result).toEqual(geometry); }); }); + +describe("proxy/transport · getDefaultGeometryItineraryQueryDeps", () => { + const itineraryInput = { + departure: { lon: 2.35, lat: 48.85 }, + arrival: { lon: 2.29, lat: 48.86 }, + profile: "pedestrian" as const, + optimize: "distance" as const, + }; + const geometry = { type: "LineString", coordinates: [[2.35, 48.85], [2.29, 48.86]] }; + + it("resolves the itinerary through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { + fetchJSONGetWithLimit.mockResolvedValue({ geometry, distance: 4800, duration: 62 }); + + const result = await getDefaultGeometryItineraryQueryDeps().getItineraryLayer(itineraryInput); + + expect(fetchJSONGetWithLimit).toHaveBeenCalledOnce(); + const [url, timeoutMs, maxBytes, label] = fetchJSONGetWithLimit.mock.calls[0]; + expect(url).toContain("data.geopf.fr/navigation/itineraire"); + expect(url).toContain("start=2.35%2C48.85"); + expect(url).toContain("end=2.29%2C48.86"); + expect(url).toContain("profile=pedestrian"); + expect(url).toContain("optimization=shortest"); + expect(url).toContain("geometryFormat=geojson"); + expect(timeoutMs).toBe(10 * 1000); // PROXY_UPSTREAM_TIMEOUT (s) → ms, NOT HTTP_TIMEOUT + expect(maxBytes).toBe(26214400); // PROXY_MAX_RESPONSE_BYTES + expect(label).toBe("d'itinéraire"); + expect(rateLimit).toHaveBeenCalled(); + expect(result).toEqual({ geometry, distance: 4800, duration: 62 }); + }); + + it("shares a single GPF_NAVIGATION_PROXY rate limiter with the isoline client", async () => { + // Satisfies both clients: the isoline client accepts any geometry. + fetchJSONGetWithLimit.mockResolvedValue({ geometry, distance: 4800, duration: 62 }); + + await getDefaultGeometryIsolineQueryDeps().getGeometry({ + lon: 2.35, + lat: 48.85, + cost_type: "time", + cost_value: 15, + profile: "car", + }); + await getDefaultGeometryItineraryQueryDeps().getItineraryLayer(itineraryInput); + + expect(rateLimiterNames.filter((name) => name === "GPF_NAVIGATION_PROXY")).toHaveLength(1); + }); +}); diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index 1bdbc168..501cbcb6 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -66,6 +66,7 @@ describe("generate-mcp-docs helpers", () => { { name: "gpf_count_features" }, { name: "gpf_get_features" }, { name: "gpf_isoline_layer" }, + { name: "gpf_itinerary_layer" }, { name: "gpf_get_feature_by_id" }, { name: "adminexpress" }, { name: "gpf_get_features_layer" }, @@ -84,6 +85,7 @@ describe("generate-mcp-docs helpers", () => { "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", "gpf_isoline_layer", + "gpf_itinerary_layer", "unknown_custom_tool", ]); }); diff --git a/test/tools/distance.test.ts b/test/tools/distance.test.ts index 7d575155..65233a9b 100644 --- a/test/tools/distance.test.ts +++ b/test/tools/distance.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, it, expect, vi } from "vitest"; 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 { navigationItineraryClient, ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS } from "../../src/gpf/itinerary.js"; import { ellipsoidalDistance, haversine } from "../../src/helpers/distance.js"; describe("Test DistanceTool", () => { @@ -103,4 +103,42 @@ describe("Test DistanceTool", () => { expect(getItinerarySpy).toHaveBeenCalledWith({ departure, arrival, profile: "car", optimize: "distance" }); }); + + it.each(["car", "pedestrian"])("should reject a %s point outside the navigation service extent", async (profile) => { + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockRejectedValue(new Error("must not be called")); + + const response = await new DistanceTool().toolCall({ + params: { + name: "distance", + // Berlin lies north of the navigation service extent. + arguments: { departure, arrival: { lon: 13.405, lat: 52.52 }, profile }, + }, + }); + + expect(expectErrorText(response)).toContain("arrival.lat: Le point est hors de l'emprise du service de navigation"); + expect(getItinerarySpy).not.toHaveBeenCalled(); + }); + + it("should reject a pedestrian pair beyond the crow-flies cap", async () => { + const getItinerarySpy = vi.spyOn(navigationItineraryClient, "getItinerary").mockRejectedValue(new Error("must not be called")); + + const response = await new DistanceTool().toolCall({ + params: { + name: "distance", + // Saint-Quentin -> Dijon: ~300 km apart. + arguments: { departure: { lon: 3.274356, lat: 49.839862 }, arrival: { lon: 5.044572, lat: 47.326213 }, profile: "pedestrian" }, + }, + }); + + expect(expectErrorText(response)).toContain(`ne peut pas dépasser ${ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS / 1000} km`); + expect(getItinerarySpy).not.toHaveBeenCalled(); + }); + + it("should accept a point outside the navigation service extent for a crow-flies profile", async () => { + const response = await new DistanceTool().toolCall({ + params: { name: "distance", arguments: { departure, arrival: { lon: 13.405, lat: 52.52 } } }, + }); + + expect(response.isError).toBeUndefined(); + }); }); diff --git a/test/tools/gpf-isoline-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts index d726952d..cd8bb437 100644 --- a/test/tools/gpf-isoline-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -2,7 +2,7 @@ import { vi, describe, it, expect, afterEach } from "vitest"; import type { Env } from "../../src/config/env.js"; import { decodeToken } from "../../src/proxy/token.js"; -import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation.js"; +import { NAVIGATION_BBOX, NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation.js"; import { PROXY_TOKEN_KIND, gpfIsolineLayerInputSchema } from "../../src/wfs/schema.js"; import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema"; @@ -224,6 +224,44 @@ describe("Test GpfIsolineLayerTool", () => { expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'kind' n'est pas reconnu."); }); + it("publishes the navigation service extent as coordinate bounds", () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsolineLayerTool(); + const [west, south, east, north] = NAVIGATION_BBOX; + + expect(tool.toolDefinition.inputSchema.properties).toMatchObject({ + lon: { minimum: west, maximum: east }, + lat: { minimum: south, maximum: north }, + }); + }); + + it("rejects a point outside the navigation service extent", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsolineLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isoline_layer", + arguments: { + // Berlin lies north of the navigation service extent. + lon: 13.405, + lat: 52.52, + profile: "car", + cost_type: "time", + cost_value: 10, + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("lat: Le point est hors de l'emprise du service de navigation"); + expect(textContent.text).toContain(String(NAVIGATION_BBOX[3])); + }); + it("rejects a missing profile", async () => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); diff --git a/test/tools/gpf-itinerary-layer.test.ts b/test/tools/gpf-itinerary-layer.test.ts new file mode 100644 index 00000000..a3a169b1 --- /dev/null +++ b/test/tools/gpf-itinerary-layer.test.ts @@ -0,0 +1,288 @@ +import { vi, describe, it, expect, afterEach } from "vitest"; + +import type { Env } from "../../src/config/env.js"; +import { decodeToken } from "../../src/proxy/token.js"; +import { PROXY_TOKEN_KIND } from "../../src/wfs/schema.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema"; +import { ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS } from "../../src/gpf/itinerary.js"; +import { NAVIGATION_BBOX } from "../../src/gpf/navigation.js"; + +const SECRET_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SECRET = Buffer.from(SECRET_HEX, "hex"); + +const mockGetEnv = vi.fn<() => Env>(); + +vi.doMock("../../src/config/env.js", async () => { + const actual = await vi.importActual( + "../../src/config/env.js", + ); + mockGetEnv.mockImplementation(actual.getEnv); + return { + ...actual, + getEnv: mockGetEnv, + }; +}); + +const { default: GpfItineraryLayerTool } = await import( + "../../src/tools/GpfItineraryLayerTool" +); + +function makeEnv(overrides: Partial): Env { + return { + TRANSPORT_TYPE: "http", + PROXY_URL_SECRET: SECRET, + PROXY_PUBLIC_BASE_URL: "https://proxy.example.test", + PROXY_ENDPOINT: "/api/v1/proxy", + ...overrides, + } as Env; +} + +describe("Test GpfItineraryLayerTool", () => { + afterEach(() => { + vi.clearAllMocks(); + mockGetEnv.mockReset(); + }); + + it("fails fast when no proxy is configured", async () => { + mockGetEnv.mockReturnValue( + makeEnv({ PROXY_URL_SECRET: undefined, PROXY_PUBLIC_BASE_URL: undefined }), + ); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure: { lon: 2.337306, lat: 48.849319, }, + arrival: { lon: 2.352, lat: 48.866, }, + optimize: "time", + profile: "pedestrian", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("PROXY_URL_SECRET"); + }); + + it("builds an opaque data_url that round-trips to the tagged itinerary params", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure: { lon: 2.337306, lat: 48.849319, }, + arrival: { lon: 2.352, lat: 48.866, }, + profile: "car", + optimize: "distance", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const payload = JSON.parse(textContent.text); + expect(payload).toEqual(response.structuredContent); + expect( + validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + ), + ).toBeNull(); + + const url = new URL(payload.data_url); + const token = url.pathname.slice("/api/v1/proxy/".length, -".json".length); + const decoded = decodeToken(token, SECRET); + expect(decoded).toEqual({ + kind: PROXY_TOKEN_KIND.itinerary, + departure: { lon: 2.337306, lat: 48.849319, }, + arrival: { lon: 2.352, lat: 48.866, }, + profile: "car", + optimize: "distance", + }); + }); + + it("defaults optimize to time, like the `distance` tool", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure: { lon: 2.337306, lat: 48.849319 }, + arrival: { lon: 2.352, lat: 48.866 }, + profile: "car", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const { data_url: dataUrl } = response.structuredContent as { data_url: string }; + const token = new URL(dataUrl).pathname.slice("/api/v1/proxy/".length, -".json".length); + expect(decodeToken(token, SECRET)).toMatchObject({ optimize: "time" }); + expect(tool.toolDefinition.inputSchema.required).not.toContain("optimize"); + }); + + it("rejects an invalid profile", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure: { lon: 2.337306, lat: 48.849319, }, + arrival: { lon: 2.352, lat: 48.866, }, + optimize: "time", + profile: "bike", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain(`profile`); + }); + + it("rejects a pedestrian departure/arrival pair beyond the crow-flies cap", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + // Saint-Quentin -> Dijon: ~300 km apart, over the pedestrian cap. + departure: { lon: 3.274356, lat: 49.839862, }, + arrival: { lon: 5.044572, lat: 47.326213, }, + optimize: "time", + profile: "pedestrian", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain(`309 km`); + expect(textContent.text).toContain(`ne peut pas dépasser ${ITINERARY_PEDESTRIAN_MAX_DIRECT_DISTANCE_METERS / 1000} km`); + }); + + it("accepts a long car itinerary", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + // Brest -> Menton: ~1050 km apart. + departure: { lon: -4.4861, lat: 48.3904, }, + arrival: { lon: 7.4975, lat: 43.7745, }, + profile: "car", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + }); + + it("rejects a point outside the navigation service extent", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + // Paris -> Berlin: Berlin lies north of the upstream bbox. + departure: { lon: 2.3522, lat: 48.8566, }, + arrival: { lon: 13.405, lat: 52.52, }, + profile: "car", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("arrival.lat: Le point est hors de l'emprise du service de navigation"); + expect(textContent.text).toContain(String(NAVIGATION_BBOX[3])); + }); + + it("rejects an unknown key in a point", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure: { lon: 2.3522, lat: 48.8566, alt: 35 }, + arrival: { lon: 2.2945, lat: 48.8584, }, + profile: "car", + }, + }, + }); + + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'alt' n'est pas reconnu."); + }); + + it("publishes the navigation service extent as coordinate bounds", () => { + const tool = new GpfItineraryLayerTool(); + const [west, south, east, north] = NAVIGATION_BBOX; + + expect(tool.toolDefinition.inputSchema.properties?.departure).toMatchObject({ + properties: { + lon: { minimum: west, maximum: east }, + lat: { minimum: south, maximum: north }, + }, + }); + }); + + it("accepts a pair under the crow-flies cap", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + // Saint-Quentin -> Laon: ~40 km apart. + departure: { lon: 3.274356, lat: 49.839862, }, + arrival: { lon: 3.623693, lat: 49.564267, }, + optimize: "distance", + profile: "pedestrian", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const { data_url: dataUrl } = response.structuredContent as { data_url: string }; + const token = new URL(dataUrl).pathname.slice("/api/v1/proxy/".length, -".json".length); + expect(decodeToken(token, SECRET)).toMatchObject({ + kind: PROXY_TOKEN_KIND.itinerary, + arrival: { lon: 3.623693, lat: 49.564267, }, + }); + }); +}); diff --git a/test/tools/wfs/getFeatureById.test.ts b/test/tools/wfs/getFeatureById.test.ts index 45e3e777..99968a0b 100644 --- a/test/tools/wfs/getFeatureById.test.ts +++ b/test/tools/wfs/getFeatureById.test.ts @@ -95,7 +95,7 @@ describe("Test GpfGetFeatureByIdTool", () => { default: [], description: "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n"+ "`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave.\n"+ - "`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`.\n"+ + "`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84.\n"+ "`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n"+ "`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n"+ "Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\n"+ diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index 4caa7c0d..b01f4752 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -29,6 +29,9 @@ vi.doMock("../../../src/helpers/http.js", () => ({ const { gpfGetFeaturesInputSchema } = await import( "../../../src/wfs/schema.js" ); +const { NAVIGATION_BBOX } = await import( + "../../../src/gpf/navigation.js" +); const { default: GpfGetFeaturesTool } = await import( "../../../src/tools/GpfGetFeaturesTool" ); @@ -223,7 +226,7 @@ describe("Test GpfGetFeaturesTool", () => { expect(tool.toolDefinition.inputSchema.properties?.spatial_extras).toMatchObject({ description: expect.stringContaining( "Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.\n" + - "`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.\n", + "`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.\n", ), }); expect(tool.toolDefinition.outputSchema).toBeUndefined(); @@ -241,11 +244,12 @@ describe("Test GpfGetFeaturesTool", () => { distance_m: expect.objectContaining({ type: "number" }), }), }); + const [west, south, east, north] = NAVIGATION_BBOX; expect(tool.toolDefinition.inputSchema.properties?.isoline_filter).toMatchObject({ type: "object", properties: expect.objectContaining({ - lon: expect.objectContaining({ type: "number" }), - lat: expect.objectContaining({ type: "number" }), + lon: expect.objectContaining({ type: "number", minimum: west, maximum: east }), + lat: expect.objectContaining({ type: "number", minimum: south, maximum: north }), cost_type: expect.objectContaining({ enum: ["time", "distance"] }), // The filter's lower time limit, not the isoline service's 600 minutes. cost_value: expect.objectContaining({ type: "number", description: expect.stringContaining("maximum : 120)") }), diff --git a/test/wfs/geometry.test.ts b/test/wfs/geometry.test.ts index 9a6ebdfe..2f773e0f 100644 --- a/test/wfs/geometry.test.ts +++ b/test/wfs/geometry.test.ts @@ -135,6 +135,10 @@ describe("isGeometryLike", () => { it("returns false when type is not a string", () => { expect(isGeometryLike({ type: 42, coordinates: [] })).toBe(false); }); + + it("returns false when coordinates is not an array", () => { + expect(isGeometryLike({ type: "Point", coordinates: { lon: 0, lat: 0 } })).toBe(false); + }); }); describe("dropEmptyRings", () => {