From 6d8f29bde72bcb8d06b022b18530818ff7107441 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Wed, 19 Aug 2026 15:10:33 +0200 Subject: [PATCH] feat: add GpfItineraryLayer tool --- README.md | 1 + docs/config.md | 8 +- docs/dev.md | 2 +- docs/mcp-tools.md | 127 +++++++++++++ scripts/generate-mcp-docs.mjs | 1 + src/config/env.ts | 3 + src/gpf/itinerary.ts | 104 ++++++++++ src/gpf/navigation.ts | 10 +- src/gpf/navigationRateLimiter.ts | 15 ++ src/proxy/execute.ts | 44 +++++ src/proxy/server.ts | 9 + src/proxy/transport.ts | 55 +++++- src/tools/GpfItineraryLayerTool.ts | 108 +++++++++++ src/wfs/schema.ts | 64 +++++++ test/gpf/itinerary.test.ts | 93 +++++++++ test/integration/samples.ts | 1 + test/proxy/execute.test.ts | 55 +++++- test/proxy/server.test.ts | 55 ++++++ test/scripts/generate-mcp-docs.test.ts | 2 + test/tools/gpf-itinerary-layer.test.ts | 251 +++++++++++++++++++++++++ 20 files changed, 994 insertions(+), 14 deletions(-) create mode 100644 src/gpf/itinerary.ts create mode 100644 src/gpf/navigationRateLimiter.ts create mode 100644 src/tools/GpfItineraryLayerTool.ts create mode 100644 test/gpf/itinerary.test.ts create mode 100644 test/tools/gpf-itinerary-layer.test.ts diff --git a/README.md b/README.md index 0533765..4ba531c 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | Décrire une couche GPF | `gpf_describe_type` | [gpf-schema-store](https://github.com/ignfab/gpf-schema-store) | Lister les champs disponibles | | Interroger une couche GPF | `gpf_get_features` | [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/) | Extraire des objets | | 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 | | Compter les objets résultats d'une interrogation de couche GPF | `gpf_count_features` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Compter les bâtiments d'une zone | | Récupérer un objet par identifiant | `gpf_get_feature_by_id` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Charger une commune précise | | 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 | diff --git a/docs/config.md b/docs/config.md index 75a9828..ceffbd2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,7 +18,7 @@ | `GPF_WFS_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le WFS de la Géoplateforme. | 30 | | `GPF_GEOCODE_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'autocomplétion de la Géoplateforme. | 50 | | `GPF_ALTI_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'altimétrie de la Géoplateforme. | 50 | -| `GPF_NAVIGATION_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'isochrone/navigation de la Géoplateforme. | 5 | +| `GPF_NAVIGATION_RATE_LIMIT` | Nombre maximum de requêtes par seconde sur le service d'isochrone/navigation de la Géoplateforme, pour les appels émis par le serveur MCP. | 5 | | `GPF_WFS_MINISEARCH_OPTIONS` | Chaîne JSON optionnelle permettant de configurer `gpf_search_types`. | options par défaut de `@ignfab/gpf-schema-store` | | `LOG_FORMAT` | Le format d'écriture des logs : "json" ou "simple". | "simple" | | `LOG_LEVEL` | Le niveau d'écriture des logs : ["error", "info", ou "debug"](https://github.com/winstonjs/winston#logging-levels) | "debug" | @@ -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 `travel_time`), 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 `travel_time`) reste sous le délai du navigateur/Carto. | `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 le filtre `isoline` 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. | `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 d42a2eb..35c85d6 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -63,7 +63,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. Ils sont listés dans tous les transports mais échouent tant qu'aucun proxy joignable n'est configuré. Comme le proxy 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. Ils sont listés dans tous les transports mais échouent tant qu'aucun proxy joignable n'est configuré. Comme le proxy 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 local via `PROXY_PUBLIC_BASE_URL`. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 0b0d449..5a28ccd 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -61,6 +61,7 @@ Tous les tools exposent les mêmes annotations MCP dans leur définition `tools/ - [`gpf_describe_type`](#gpf_describe_type) - [`gpf_get_features`](#gpf_get_features) - [`gpf_isoline_layer`](#gpf_isoline_layer) +- [`gpf_itinerary_layer`](#gpf_itinerary_layer) - [`gpf_get_features_layer`](#gpf_get_features_layer) - [`gpf_count_features`](#gpf_count_features) - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) @@ -1531,6 +1532,132 @@ L'URL est opaque et doit être transmise telle quelle à un outil cartographique | Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. | +## `gpf_itinerary_layer` + +Code Source : [src/tools/GpfItineraryLayerTool.ts](../src/tools/GpfItineraryLayerTool.ts) + +### Titre + +Couche cartographiable d'itinéraire GPF + +### Description du tool + +``` +Renvoie une **URL de couche cartographiable** (`data_url`) représentant l'itinéraire entre deux points calculé par la Géoplateforme. +Utiliser `departure_lon`/`departure_lat` pour le départ, `arrival_lon`/`arrival_lat` pour l'arrivée, `profile` pour le mode de déplacement (`car` ou `pedestrian`) et `optimize` pour choisir entre l'itinéraire le plus rapide (`time`, défaut) ou le plus court (`distance`). +La couche GeoJSON retournée contient une Feature LineString avec les propriétés `distance_meters` et `duration_minutes`. +Le départ et l'arrivée doivent être distants de moins de 100 km à vol d'oiseau. +L'URL est opaque et doit être transmise telle quelle à un outil cartographique (MCP Carto, ...). +(source : Géoplateforme (calcul d'itinéraire)). +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `arrival_lat` | number | oui | Latitude du point d'arrivée en WGS84 `lon/lat`. | +| `arrival_lon` | number | oui | Longitude du point d'arrivée en WGS84 `lon/lat`. | +| `departure_lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. | +| `departure_lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. | +| `optimize` | string (enum) | non | Métrique d'optimisation : `time` (itinéraire le plus rapide, défaut) ou `distance` (le plus court). Valeurs : time, distance. | +| `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_lon": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude du point de départ en WGS84 `lon/lat`." + }, + "departure_lat": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude du point de départ en WGS84 `lon/lat`." + }, + "arrival_lon": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude du point d'arrivée en WGS84 `lon/lat`." + }, + "arrival_lat": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude du point d'arrivée en WGS84 `lon/lat`." + }, + "profile": { + "type": "string", + "enum": [ + "car", + "pedestrian" + ], + "description": "Mode de déplacement : `car` ou `pedestrian`." + }, + "optimize": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "description": "Métrique d'optimisation : `time` (itinéraire le plus rapide, défaut) ou `distance` (le plus court)." + } + }, + "required": [ + "departure_lon", + "departure_lat", + "arrival_lon", + "arrival_lat", + "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 | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. | + ## `gpf_get_features_layer` Code Source : [src/tools/GpfGetFeaturesLayerTool.ts](../src/tools/GpfGetFeaturesLayerTool.ts) diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index 9a0ec3c..fba058f 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -20,6 +20,7 @@ const toolDisplayOrder = [ "gpf_describe_type", "gpf_get_features", "gpf_isoline_layer", + "gpf_itinerary_layer", "gpf_get_features_layer", "gpf_count_features", "gpf_get_feature_by_id", diff --git a/src/config/env.ts b/src/config/env.ts index 6d13435..626a8c6 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -143,6 +143,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 new file mode 100644 index 0000000..1ca7f28 --- /dev/null +++ b/src/gpf/itinerary.ts @@ -0,0 +1,104 @@ +import { fetchJSONGet } from "../helpers/http.js"; +import logger from "../logger.js"; +import type { JsonFetcher } from "../helpers/http.js"; +import type { RateLimiter } from "../helpers/RateLimiter.js"; +import { NAVIGATION_COST_TYPES, type NavigationProfile, type GeoJsonGeometryLike, isGeoJsonGeometryLike } from "./navigation.js"; + +export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)"; +export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire"; +export const ITINERARY_RESOURCE = "bdtopo-osrm"; + +/** + * Maximum crow-flies distance accepted between departure and arrival. Caps the + * upstream compute and the size of the returned LineString: a route this long + * already yields thousands of vertices. + */ +export const ITINERARY_MAX_DIRECT_DISTANCE_METERS = 100_000; + +// Same travel modes and same cost metrics as the isoline service +export type ItineraryProfile = NavigationProfile; +export type ItineraryMetric = typeof NAVIGATION_COST_TYPES[number]; + +export type ItineraryGeometryInput = { + departure: { + lon: number; + lat: number; + }; + arrival: { + lon: number; + lat: number; + }; + profile: ItineraryProfile; + /** Metric the route is optimized for: `time` (fastest) or `distance` (shortest). Defaults to `time`. */ + optimize?: ItineraryMetric; +}; + +/** + * Builds the itinerary request URL. + */ +function buildItineraryUrl(input: ItineraryGeometryInput): string { + const urlsearch = new URLSearchParams({ + resource: ITINERARY_RESOURCE, + start: `${input.departure.lon},${input.departure.lat}`, + end: `${input.arrival.lon},${input.arrival.lat}`, + profile: input.profile, + optimization: input.optimize === "distance" ? "shortest" : "fastest", + timeUnit: "minute", + distanceUnit: "meter", + crs: "EPSG:4326", + geometryFormat: "geojson", + getSteps: "false", + getBbox: "false", + }); + return `${NAVIGATION_ITINERARY_URL}?${urlsearch.toString()}`; +} + +/** + * Validates the numeric cost fields the client promises its callers, and returns them + * narrowed. Throws if the service omitted either one. + */ +function parseItineraryCosts(distance: unknown, duration: unknown): { distance: number; duration: number } { + 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 (distance=${distance}, duration=${duration}).`, + ); + } + return { distance, duration }; +} + +type ItineraryLayerRawResponse = { + geometry?: unknown; + distance?: unknown; + duration?: unknown; +}; + +export type ItineraryWithGeometryResult = { + geometry: GeoJsonGeometryLike; + distance: number; + duration: number; +}; + +/** + * Client for the itinerary layer tool. Requests the route with `geometryFormat: "geojson"` + * so the response includes the route geometry (a LineString) alongside distance and duration. + */ +export class NavigationItineraryLayerClient { + constructor( + private rateLimiter: RateLimiter, + private fetcher: JsonFetcher = fetchJSONGet, + ) {} + + async getItineraryWithGeometry(input: ItineraryGeometryInput): Promise { + await this.rateLimiter.limit(); + logger.debug(`[gpf:navigation] getItineraryWithGeometry(${JSON.stringify(input)})...`); + + const result = await this.fetcher(buildItineraryUrl(input)); + if (!isGeoJsonGeometryLike(result.geometry)) { + throw new Error("Le service d'itinéraire n'a pas renvoyé de géométrie GeoJSON exploitable."); + } + return { + geometry: result.geometry, + ...parseItineraryCosts(result.distance, result.duration), + }; + } +} diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 0b9c04f..c9bced7 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -1,8 +1,8 @@ import { fetchJSONGet } from "../helpers/http.js"; import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; -import { RateLimiter } from "../helpers/RateLimiter.js"; -import { getEnv } from "../config/env.js"; +import type { RateLimiter } from "../helpers/RateLimiter.js"; +import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)"; export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; @@ -33,7 +33,7 @@ export type IsolineGeometryInput = { profile: NavigationProfile; }; -function isGeoJsonGeometryLike(value: unknown): value is GeoJsonGeometryLike { +export function isGeoJsonGeometryLike(value: unknown): value is GeoJsonGeometryLike { return ( typeof value === "object" && value !== null && @@ -78,9 +78,7 @@ export class NavigationIsochroneClient { let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined; function getDefaultNavigationIsochroneClient() { - defaultNavigationIsochroneClient ??= new NavigationIsochroneClient( - new RateLimiter({ name: "GPF_NAVIGATION", maxCalls: getEnv().GPF_NAVIGATION_RATE_LIMIT, period: 1 }), - ); + defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter()); return defaultNavigationIsochroneClient; } diff --git a/src/gpf/navigationRateLimiter.ts b/src/gpf/navigationRateLimiter.ts new file mode 100644 index 0000000..cda668d --- /dev/null +++ b/src/gpf/navigationRateLimiter.ts @@ -0,0 +1,15 @@ +import { RateLimiter } from "../helpers/RateLimiter.js"; +import { getEnv } from "../config/env.js"; + +// Single RateLimiter instance for every MCP call to the data.geopf.fr/navigation +// service, so that all of them draw on one GPF_NAVIGATION_RATE_LIMIT budget. +let sharedNavigationRateLimiter: RateLimiter | undefined; + +export function getNavigationRateLimiter(): RateLimiter { + sharedNavigationRateLimiter ??= new RateLimiter({ + name: "GPF_NAVIGATION", + maxCalls: getEnv().GPF_NAVIGATION_RATE_LIMIT, + period: 1, + }); + return sharedNavigationRateLimiter; +} diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index 39971fd..9079613 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -43,7 +43,9 @@ import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput, GpfIsolineLayerInput, + GpfItineraryLayerInput, } from "../wfs/schema.js"; +import type { ItineraryWithGeometryResult } from "../gpf/itinerary.js"; // --- Injected Dependencies --- @@ -255,6 +257,14 @@ export type GeometryIsolineQueryDeps = { getGeometry: IsolineGeometryResolver; }; +export type ItineraryWithGeometryResolver = ( + input: GpfItineraryLayerInput, +) => Promise; + +export type GeometryItineraryQueryDeps = { + getItineraryWithGeometry: ItineraryWithGeometryResolver; +}; + /** * Executes a single-feature by-id lookup and returns the RAW FeatureCollection * with full geometry (for map rendering by MCP Carto). @@ -354,3 +364,37 @@ export async function runGeometryIsolineQuery( ] }; } + +/** + * 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, distance, duration } = await deps.getItineraryWithGeometry(input); + + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry, + properties: { + profile: input.profile, + optimize: input.optimize ?? "time", + distance_meters: distance, + duration_minutes: duration, + }, + } + ] + }; +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 8243287..67cec2c 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 cachedProxyIsochroneClient; @@ -171,3 +189,36 @@ export function getDefaultGeometryIsolineQueryDeps(): GeometryIsolineQueryDeps { getGeometry: (input) => getProxyIsochroneClient().getGeometry(input), }; } + +// --- Proxy Itinerary Client (singleton) --- + +let cachedProxyItineraryLayerClient: NavigationItineraryLayerClient | undefined; + +/** + * Returns the proxy itinerary layer client: a dedicated `NavigationItineraryLayerClient` + * 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 isochrone client. + * Lazily built so the bounds are read from a fully-parsed environment. + */ +function getProxyItineraryLayerClient(): NavigationItineraryLayerClient { + cachedProxyItineraryLayerClient ??= new NavigationItineraryLayerClient( + getProxyNavigationRateLimiter(), + (url) => fetchJSONGetWithLimit(url, getEnv().PROXY_UPSTREAM_TIMEOUT * 1000, getEnv().PROXY_MAX_RESPONSE_BYTES, "d'itinéraire"), + ); + return cachedProxyItineraryLayerClient; +} + +/** + * Default dependency bundle for `runGeometryItineraryQuery`. + */ +export function getDefaultGeometryItineraryQueryDeps(): GeometryItineraryQueryDeps { + return { + getItineraryWithGeometry: (input) => + getProxyItineraryLayerClient().getItineraryWithGeometry({ + departure: { lon: input.departure_lon, lat: input.departure_lat }, + arrival: { lon: input.arrival_lon, lat: input.arrival_lat }, + profile: input.profile, + optimize: input.optimize, + }), + }; +} diff --git a/src/tools/GpfItineraryLayerTool.ts b/src/tools/GpfItineraryLayerTool.ts new file mode 100644 index 0000000..27a3c85 --- /dev/null +++ b/src/tools/GpfItineraryLayerTool.ts @@ -0,0 +1,108 @@ +/** + * 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_OPEN_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_MAX_DIRECT_DISTANCE_METERS, NAVIGATION_ITINERARY_SOURCE } from "../gpf/itinerary.js"; +import logger from "../logger.js"; + +// --- Tool --- + +class GpfItineraryLayerTool extends BaseTool { + name = "gpf_itinerary_layer"; + title = "Couche cartographiable d'itinéraire GPF"; + annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; + description = [ + "Renvoie une **URL de couche cartographiable** (`data_url`) représentant l'itinéraire entre deux points calculé par la Géoplateforme.", + "Utiliser `departure_lon`/`departure_lat` pour le départ, `arrival_lon`/`arrival_lat` pour l'arrivée, `profile` pour le mode de déplacement (`car` ou `pedestrian`) et `optimize` pour choisir entre l'itinéraire le plus rapide (`time`, défaut) ou le plus court (`distance`).", + "La couche GeoJSON retournée contient une Feature LineString avec les propriétés `distance_meters` et `duration_minutes`.", + `Le départ et l'arrivée doivent être distants de moins de ${ITINERARY_MAX_DIRECT_DISTANCE_METERS / 1000} km à vol d'oiseau.`, + "L'URL est opaque et doit être transmise telle quelle à un outil cartographique (MCP Carto, ...).", + `(source : ${NAVIGATION_ITINERARY_SOURCE}).`, + ].join("\n"); + 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/wfs/schema.ts b/src/wfs/schema.ts index 8356e9a..5e4e229 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -17,6 +17,8 @@ import { NAVIGATION_MAX_TIME_MINUTES, type NavigationCostType, } from "../gpf/navigation.js"; +import { ITINERARY_MAX_DIRECT_DISTANCE_METERS } from "../gpf/itinerary.js"; +import distance from "../helpers/distance.js"; // --- Shared Constants --- @@ -315,6 +317,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]; @@ -366,6 +369,67 @@ export type GpfIsolineLayerInput = z.infer; export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema); +// --- `gpf_itinerary_layer` (proxy) --- + +const itineraryProfileSchema = z + .enum(NAVIGATION_PROFILES) + .describe("Mode de déplacement : `car` ou `pedestrian`."); + +export const gpfItineraryLayerInputObjectSchema = z.object({ + departure_lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."), + departure_lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), + arrival_lon: lonSchema.describe("Longitude du point d'arrivée en WGS84 `lon/lat`."), + arrival_lat: latSchema.describe("Latitude du point d'arrivée en WGS84 `lon/lat`."), + profile: itineraryProfileSchema, + optimize: z + .enum(NAVIGATION_COST_TYPES) + .optional() + .describe("Métrique d'optimisation : `time` (itinéraire le plus rapide, défaut) ou `distance` (le plus court)."), +}).strict(); + +/** + * Caps the crow-flies span of an itinerary request. The upstream service accepts any + * pair of points, but a very long route costs proportionally more to compute and + * returns a LineString with thousands of vertices, so it is rejected up front rather + * than truncated downstream by `PROXY_MAX_RESPONSE_BYTES`. + * + * 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: { + departure_lon: number; + departure_lat: number; + arrival_lon: number; + arrival_lat: number; + }, + ctx: z.RefinementCtx, +) { + const dist = distance( + { type: "Point", coordinates: [input.departure_lon, input.departure_lat] }, + { type: "Point", coordinates: [input.arrival_lon, input.arrival_lat] }, + ); + + if (dist > ITINERARY_MAX_DIRECT_DISTANCE_METERS) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: ITINERARY_MAX_DIRECT_DISTANCE_METERS, + type: "number", + inclusive: true, + message: `La distance à vol d'oiseau entre le départ et l'arrivée (${Math.round(dist / 1000)} km) ne peut pas dépasser ${ITINERARY_MAX_DIRECT_DISTANCE_METERS/1000} km.`, + }); + } +} + +// 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 new file mode 100644 index 0000000..c621edf --- /dev/null +++ b/test/gpf/itinerary.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { NavigationItineraryLayerClient } from "../../src/gpf/itinerary.js"; +import { RateLimiter } from "../../src/helpers/RateLimiter.js"; + +describe("NavigationItineraryLayerClient", () => { + 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 NavigationItineraryLayerClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + fetcher, + ); + } + + 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.getItineraryWithGeometry({ + 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-osrm"); + 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 optimize for distance when `optimize` is `distance`", async () => { + const urls: string[] = []; + const client = buildClient(async (url) => { + urls.push(url); + return { geometry: ROUTE_GEOMETRY, distance: 48231, duration: 42 }; + }); + + await client.getItineraryWithGeometry({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 3.623693, lat: 49.564267 }, + profile: "pedestrian", + optimize: "distance", + }); + + const parsedUrl = new URL(urls[0]); + expect(parsedUrl.searchParams.get("optimization")).toEqual("shortest"); + expect(parsedUrl.searchParams.get("profile")).toEqual("pedestrian"); + }); + + it("should reject responses without an exploitable GeoJSON geometry", async () => { + const client = buildClient(async () => ({ distance: 48231, duration: 42 })); + + await expect(client.getItineraryWithGeometry({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 3.623693, lat: 49.564267 }, + profile: "car", + })).rejects.toThrow(/n'a pas renvoyé de géométrie GeoJSON/); + }); + + it("should reject responses without a numeric distance and duration", async () => { + const client = buildClient(async () => ({ geometry: ROUTE_GEOMETRY, distance: 48231 })); + + await expect(client.getItineraryWithGeometry({ + departure: { lon: 3.274356, lat: 49.839862 }, + arrival: { lon: 3.623693, lat: 49.564267 }, + profile: "car", + })).rejects.toThrow(/n'a pas renvoyé de distance et de durée/); + }); +}); diff --git a/test/integration/samples.ts b/test/integration/samples.ts index e444e1a..a4dcdfd 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -25,6 +25,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 c256aaa..a00ee51 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"; @@ -450,3 +450,56 @@ describe("proxy/execute · runGeometryIsolineQuery", () => { expect(calls).toEqual([{ lon: 2.35, lat: 48.85, costType: "time", costValue: 15, profile: "pedestrian" }]); }); }); + +describe("proxy/execute · runGeometryItineraryQuery", () => { + const itineraryInput = { + departure_lon: 2.33, departure_lat: 48.84, + arrival_lon: 2.35, arrival_lat: 48.85, + profile: "car" as const, + }; + const routeGeometry = { type: "LineString", coordinates: [[2.33, 48.84], [2.35, 48.85]] }; + + it("returns the itinerary as a FeatureCollection", async () => { + const result = await runGeometryItineraryQuery(itineraryInput, { + getItineraryWithGeometry: async () => ({ geometry: routeGeometry, distance: 3200, duration: 5.5 }), + }); + + expect(result).toEqual({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: routeGeometry, + properties: { + profile: "car", + optimize: "time", + distance_meters: 3200, + duration_minutes: 5.5, + }, + } + ] + }); + }); + + it("echoes the explicit optimize value into properties", async () => { + const result = await runGeometryItineraryQuery( + { ...itineraryInput, optimize: "distance" }, + { getItineraryWithGeometry: async () => ({ geometry: routeGeometry, distance: 3000, duration: 6.0 }) }, + ); + + expect(result.features?.[0]?.properties).toMatchObject({ optimize: "distance" }); + }); + + it("forwards the flat input coords to the resolver", async () => { + const calls: unknown[] = []; + + await runGeometryItineraryQuery(itineraryInput, { + getItineraryWithGeometry: 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 bcf221c..24e5f48 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -13,15 +13,18 @@ import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/h 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: () => ({ getItineraryWithGeometry: vi.fn() }), })); // A fixed 32-byte hex key for the test environment. @@ -64,6 +67,17 @@ function validIsolineToken() { }, KEY); } +function validItineraryToken() { + return encodeToken({ + kind: PROXY_TOKEN_KIND.itinerary, + departure_lon: 2.33, + departure_lat: 48.84, + arrival_lon: 2.35, + arrival_lat: 48.85, + profile: "car", + }, KEY); +} + beforeAll(async () => { process.env.TRANSPORT_TYPE = "http"; process.env.PROXY_URL_SECRET = TEST_SECRET; @@ -90,6 +104,7 @@ beforeEach(() => { runGeometryFeatureQuery.mockReset(); runGeometryFeatureByIdQuery.mockReset(); runGeometryIsolineQuery.mockReset(); + runGeometryItineraryQuery.mockReset(); }); describe("proxy/server", () => { @@ -239,6 +254,46 @@ describe("proxy/server", () => { }); }); + 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, + departure_lat: 48.84, + arrival_lon: 2.35, + arrival_lat: 48.85, + 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 100 km cap. + departure_lon: 3.274356, + departure_lat: 49.839862, + arrival_lon: 5.044572, + arrival_lat: 47.326213, + profile: "car", + }, 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/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index 29c65fa..4bfa3d6 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" }, @@ -80,6 +81,7 @@ describe("generate-mcp-docs helpers", () => { "adminexpress", "gpf_get_features", "gpf_isoline_layer", + "gpf_itinerary_layer", "gpf_get_features_layer", "gpf_count_features", "gpf_get_feature_by_id", diff --git a/test/tools/gpf-itinerary-layer.test.ts b/test/tools/gpf-itinerary-layer.test.ts new file mode 100644 index 0000000..6562067 --- /dev/null +++ b/test/tools/gpf-itinerary-layer.test.ts @@ -0,0 +1,251 @@ +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"; + +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, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_lat: 48.866, + 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("mints a data_url", async () => { + mockGetEnv.mockReturnValue(makeEnv({ TRANSPORT_TYPE: "stdio" })); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure_lon: 2.337306, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_lat: 48.866, + profile: "pedestrian", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = JSON.parse((response.content[0] as { text: string }).text); + expect(payload.data_url).toContain("https://proxy.example.test/api/v1/proxy/"); + }); + + 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, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_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, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_lat: 48.866, + profile: "car", + optimize: "distance", + }); + }); + + it("round-trips without optimize (defaults omitted from token)", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfItineraryLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_itinerary_layer", + arguments: { + departure_lon: 2.337306, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_lat: 48.866, + profile: "pedestrian", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = JSON.parse((response.content[0] as { text: string }).text); + 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).toMatchObject({ + kind: PROXY_TOKEN_KIND.itinerary, + departure_lon: 2.337306, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_lat: 48.866, + profile: "pedestrian", + }); + expect((decoded as Record).optimize).toBeUndefined(); + }); + + 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, + departure_lat: 48.849319, + arrival_lon: 2.352, + arrival_lat: 48.866, + profile: "bike", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: expect.arrayContaining([ + expect.objectContaining({ name: "profile" }), + ]), + }); + }); + it("rejects a 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, well over the 100 km cap. + departure_lon: 3.274356, + departure_lat: 49.839862, + arrival_lon: 5.044572, + arrival_lat: 47.326213, + profile: "car", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: expect.arrayContaining([ + expect.objectContaining({ detail: expect.stringContaining("vol d'oiseau") }), + ]), + }); + }); + + it("accepts a pair just 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, + departure_lat: 49.839862, + arrival_lon: 3.623693, + arrival_lat: 49.564267, + 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({ + kind: PROXY_TOKEN_KIND.itinerary, + arrival_lon: 3.623693, + arrival_lat: 49.564267, + }); + }); +});