From 753a1251b0c31e56b61eca5b7517386d937c7020 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 2 Oct 2026 16:29:54 +0200 Subject: [PATCH] feat: add GpfIsochroneLayer tool --- README.md | 1 + docs/config.md | 6 +- docs/dev.md | 2 +- docs/mcp-tools.md | 110 ++++++++++++- scripts/generate-mcp-docs.mjs | 1 + src/gpf/itinerary.ts | 6 +- src/gpf/navigation.ts | 29 ++-- src/proxy/execute.ts | 50 +++++- src/proxy/server.ts | 34 ++-- src/proxy/transport.ts | 19 ++- src/tools/GpfIsochroneLayerTool.ts | 106 +++++++++++++ src/wfs/features.ts | 9 +- src/wfs/schema.ts | 59 +++++-- test/gpf/navigation.test.ts | 11 +- test/integration/samples.ts | 1 + test/proxy/execute.test.ts | 24 ++- test/proxy/server.test.ts | 52 +++++++ test/proxy/transport.test.ts | 32 +++- test/scripts/generate-mcp-docs.test.ts | 2 + test/tools/gpf-isochrone-layer.test.ts | 208 +++++++++++++++++++++++++ 20 files changed, 697 insertions(+), 65 deletions(-) create mode 100644 src/tools/GpfIsochroneLayerTool.ts create mode 100644 test/tools/gpf-isochrone-layer.test.ts diff --git a/README.md b/README.md index 92d98258..7e1ed408 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to | 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 | | 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 | `gpf_isochrone_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte | ## Architecture en bref diff --git a/docs/config.md b/docs/config.md index 0a00ad26..dec5c140 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 `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 d'isochrone (filtre `travel_time` et `gpf_isochrone_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 `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isochrone_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` | ## Génération de `PROXY_URL_SECRET` -Pour produire des URLs opaques d'affichage cartographique (tools `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_isochrone_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 40b5ed45..2df58a47 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_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_isochrone_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 2163ef58..557eb003 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` et `gpf_get_feature_by_id_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` et `gpf_isochrone_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -52,6 +52,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : - [`gpf_count_features`](#gpf_count_features) - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) +- [`gpf_isochrone_layer`](#gpf_isochrone_layer) - [`distance`](#distance) ## `geocode` @@ -2214,6 +2215,113 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu | 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_isochrone_layer` + +Code Source : [src/tools/GpfIsochroneLayerTool.ts](../src/tools/GpfIsochroneLayerTool.ts) + +### Titre + +Couche cartographiable d’isochrone GPF + +### Description du tool + +``` +Interroge l'isochrone autour d'un point et 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 avec une géométrie complète. +À utiliser pour afficher ou cartographier une zone de desserte. +Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal. +(source : Géoplateforme (calcul d'isochrone)). +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `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`. | +| `minutes` | number | oui | Temps de trajet maximal en minutes. Maximum : 600. | +| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`). Valeurs : car, pedestrian. | + +
+Schéma d’entrée brut + +```json +{ + "type": "object", + "properties": { + "lon": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude du point de départ en WGS84 `lon/lat`." + }, + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude du point de départ en WGS84 `lon/lat`." + }, + "minutes": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 600, + "description": "Temps de trajet maximal en minutes. Maximum : 600." + }, + "profile": { + "type": "string", + "enum": [ + "car", + "pedestrian" + ], + "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + } + }, + "required": [ + "lon", + "lat", + "minutes", + "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) diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index 4b7a4706..202d50d0 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -23,6 +23,7 @@ const toolDisplayOrder = [ "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", + "gpf_isochrone_layer", ]; /** diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index 9eba76b7..da3d8aae 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -3,14 +3,14 @@ import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; -import { TRAVEL_TIME_PROFILES, TRAVEL_TIME_RESOURCE } from "./navigation.js"; +import { NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_RESOURCE } from "./navigation.js"; export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)"; export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire"; // Same engine as the `travel_time_filter` isochrones, so that both report the // same travel times. -export const ITINERARY_RESOURCE = TRAVEL_TIME_RESOURCE; -export const ITINERARY_PROFILES = TRAVEL_TIME_PROFILES; +export const ITINERARY_RESOURCE = NAVIGATION_ISOCHRONE_RESOURCE; +export const ITINERARY_PROFILES = NAVIGATION_PROFILES; export const ITINERARY_METRICS = ["time", "distance"] as const; export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 6c8668aa..e74bcb9b 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -1,4 +1,4 @@ -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 { Geometry } from "geojson"; @@ -8,18 +8,19 @@ import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; -export const TRAVEL_TIME_RESOURCE = "bdtopo-valhalla"; +export const NAVIGATION_ISOCHRONE_RESOURCE = "bdtopo-valhalla"; +// Upstream ceiling accepted by the GPF isochrone service for a time cost. +export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600; +export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const TRAVEL_TIME_MAX_MINUTES = 120; -export const TRAVEL_TIME_PROFILES = ["car", "pedestrian"] as const; -export type TravelTimeProfile = typeof TRAVEL_TIME_PROFILES[number]; +export type NavigationProfile = typeof NAVIGATION_PROFILES[number]; - -export type TravelTimeGeometryInput = { +export type IsochroneInput = { lon: number; lat: number; minutes: number; - profile: TravelTimeProfile; + profile: NavigationProfile; }; export class NavigationIsochroneClient { @@ -28,12 +29,12 @@ export class NavigationIsochroneClient { private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet, ) {} - async getTravelTimeGeometry(input: TravelTimeGeometryInput): Promise { + async getIsochrone(input: IsochroneInput): Promise { await this.rateLimiter.limit(); - logger.debug(`[gpf:navigation] getTravelTimeGeometry(${JSON.stringify(input)})...`); + logger.debug(`[gpf:navigation] getIsochrone(${JSON.stringify(input)})...`); const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({ - resource: TRAVEL_TIME_RESOURCE, + resource: NAVIGATION_ISOCHRONE_RESOURCE, point: `${input.lon},${input.lat}`, direction: "departure", costType: "time", @@ -47,7 +48,9 @@ export class NavigationIsochroneClient { const json = await this.fetcher(url); if (!isGeometryLike(json.geometry)) { - throw new Error("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable."); + throw new ServiceResponseError("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable.", { + http: { status: 502, statusText: "Bad Gateway" }, + }); } return json.geometry; @@ -62,7 +65,7 @@ function getDefaultNavigationIsochroneClient() { } export const navigationIsochroneClient = { - getTravelTimeGeometry(input: TravelTimeGeometryInput) { - return getDefaultNavigationIsochroneClient().getTravelTimeGeometry(input); + getIsochrone(input: IsochroneInput) { + return getDefaultNavigationIsochroneClient().getIsochrone(input); }, }; diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index dc0e9568..5e0caf6c 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -1,5 +1,5 @@ /** - * Proxy-side WFS execution engine. + * Proxy-side WFS and isochrone execution engine. * * `runGeometryFeatureQuery` (entry point) compiles and runs the layer query; * `resolveReferenceGeometry` (internal helper) resolves the reference geometry @@ -37,8 +37,12 @@ import { resolveFeatureGeometry } from "../wfs/referenceGeometry.js"; import { rethrowIdentifiedCatalogDesyncError } from "../wfs/catalogDesync.js"; import { ServiceResponseError, extractJsonServiceError } from "../helpers/http.js"; import type { WfsFeatureCollectionResponse } from "../wfs/types.js"; -import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput } from "../wfs/schema.js"; -import type { Geometry } from "geojson"; +import type { FeatureCollection, Geometry } from "geojson"; +import type { + GpfGetFeaturesInput, + GpfGetFeatureByIdLayerInput, + GpfIsochroneLayerInput, +} from "../wfs/schema.js"; // --- Injected Dependencies --- @@ -302,3 +306,43 @@ export async function runGeometryFeatureByIdQuery( numberMatched: 1, }; } + +// --- Isochrone Public Engine --- + +export type IsochroneGeometryResolver = ( + input: GpfIsochroneLayerInput, +) => Promise; + +export type GeometryIsochroneQueryDeps = { + getGeometry: IsochroneGeometryResolver; +}; + +/** + * Resolves an isochrone and returns it as a GeoJSON `FeatureCollection` with full + * geometry (for map rendering by MCP Carto). + * + * Counterpart of {@link runGeometryFeatureQuery} for the isochrone producer tool. + * The request params are echoed into `properties` so the rendered layer carries + * its own legend. + * + * @param input Validated isochrone layer input (`{ lon, lat, profile, minutes }`). + * @param deps Injected isochrone geometry resolver. + * @returns The isochrone as a single GeoJSON FeatureCollection. + */ +export async function runGeometryIsochroneQuery( + input: GpfIsochroneLayerInput, + deps: GeometryIsochroneQueryDeps, +): Promise { + const geometry = await deps.getGeometry(input); + + return { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + geometry, + properties: input, + } + ] + }; +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 7d8951c8..87c52847 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -2,8 +2,8 @@ * Stateless geodata proxy HTTP server. * * Serves `GET {PROXY_ENDPOINT}/.json`: decode the opaque token, re-validate - * it through the layer schema, run the geometry-full WFS query, and return a - * GeoJSON FeatureCollection for MCP Carto to render. + * it through the layer schema, run the geometry-full WFS or isochrone query, and + * return a GeoJSON FeatureCollection for MCP Carto to render. */ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; @@ -14,13 +14,19 @@ import { GPF_WFS_URL } from "../wfs/catalog.js"; import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, + gpfIsochroneLayerInputObjectSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; -import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery } from "./execute.js"; +import { + runGeometryFeatureQuery, + runGeometryFeatureByIdQuery, + runGeometryIsochroneQuery, +} from "./execute.js"; import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js"; import { getDefaultGeometryFeatureQueryDeps, getDefaultGeometryFeatureByIdQueryDeps, + getDefaultGeometryIsochroneQueryDeps, } from "./transport.js"; import { decodeToken, @@ -72,7 +78,7 @@ function toHttpError(error: unknown): HttpError { if (error instanceof FeatureCardinalityError) { // The client request was valid but the upstream WFS broke the single-feature // contract (duplicate / id mismatch / unusable body): an upstream anomaly. - return { status: 502, detail: "Le service WFS a renvoyé une réponse incohérente pour cet objet." }; + return { status: 502, detail: "Le service amont a renvoyé une réponse incohérente pour cet objet." }; } if (error instanceof ServiceResponseError) { const upstream = error.httpStatus ?? 502; @@ -80,13 +86,13 @@ function toHttpError(error: unknown): HttpError { // timeout (504). Client-provided data already passed validation, so a 4xx here // means an upstream contract issue, not a client error on the proxy endpoint. const status = upstream === 504 ? 504 : 502; - // Do NOT forward error.serviceDetail (raw upstream WFS text, English, internal + // Do NOT forward error.serviceDetail (raw upstream WFS or GPF text, English, internal // column names) to the client: like the other branches, return a fixed FR // message. The upstream detail is logged server-side by the caller. const detail = status === 504 - ? "Le service WFS n'a pas répondu à temps." - : "Le service WFS a renvoyé une réponse inexploitable."; + ? "Le service amont n'a pas répondu à temps." + : "Le service amont a renvoyé une réponse inexploitable."; return { status, detail }; } return { status: 500, detail: "Erreur interne du proxy." }; @@ -164,7 +170,7 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise getProxyIsochroneClient().getIsochrone(input), + }; +} diff --git a/src/tools/GpfIsochroneLayerTool.ts b/src/tools/GpfIsochroneLayerTool.ts new file mode 100644 index 00000000..b4ea3e1e --- /dev/null +++ b/src/tools/GpfIsochroneLayerTool.ts @@ -0,0 +1,106 @@ +/** + * MCP tool producing an opaque, cartographiable layer URL for a Géoplateforme + * isochrone request. + * + * The tool returns a short opaque `data_url` that the LLM passes verbatim to a + * map client. Fetching it yields a GeoJSON FeatureCollection 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, + gpfIsochroneLayerInputObjectSchema, + gpfIsochroneLayerPublishedInputSchema, + type GpfIsochroneLayerInput, +} from "../wfs/schema.js"; +import { NAVIGATION_SOURCE } from "../gpf/navigation.js"; +import logger from "../logger.js"; + +const GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION = [ + "Interroge l'isochrone autour d'un point et 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 avec une géométrie complète.", + "À utiliser pour afficher ou cartographier une zone de desserte.", + "Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal.", + `(source : ${NAVIGATION_SOURCE}).`, +].join("\n"); + +// --- Tool --- + +class GpfIsochroneLayerTool extends BaseTool { + name = "gpf_isochrone_layer"; + title = "Couche cartographiable d’isochrone GPF"; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; + description = GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION; + protected outputSchemaShape = gpfGetFeaturesLayerOutputSchema; + + // The framework requires a plain Zod object here to publish a compatible input + // schema. The object schema is the full runtime contract here. + schema = gpfIsochroneLayerInputObjectSchema; + + /** + * 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 gpfIsochroneLayerPublishedInputSchema; + } + + /** + * 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 isochrone. No upstream call is + * made here: the isochrone itself is computed by the proxy when the `data_url` + * is fetched. + * + * @param input Validated isochrone layer input. + * @returns The `{ data_url }` payload carrying the opaque token. + */ + async execute(input: GpfIsochroneLayerInput) { + const env = getEnv(); + + if (!env.PROXY_URL_SECRET || !env.PROXY_PUBLIC_BASE_URL) { + throw new Error( + "`gpf_isochrone_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 = gpfIsochroneLayerInputObjectSchema.parse(input); + + logger.info(`[tool] execute ${this.name} ...`, { + input: tokenParams, + }); + + const token = encodeToken( + { kind: PROXY_TOKEN_KIND.isochrone, ...tokenParams }, + env.PROXY_URL_SECRET, + ); + + const dataUrl = buildDataUrl(env.PROXY_PUBLIC_BASE_URL, env.PROXY_ENDPOINT, token); + + return { data_url: dataUrl }; + } +} + +export default GpfIsochroneLayerTool; diff --git a/src/wfs/features.ts b/src/wfs/features.ts index 2caeb8fc..2cbaf697 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -109,12 +109,9 @@ export async function resolveTravelTimeGeometry( return undefined; } - return await navigationIsochroneClient.getTravelTimeGeometry({ - lon: spatialFilter.lon, - lat: spatialFilter.lat, - minutes: spatialFilter.minutes, - profile: spatialFilter.profile, - }); + const { operator, ...parameters } = spatialFilter; + + return await navigationIsochroneClient.getIsochrone(parameters); } /** diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 457bc330..a33b0d2d 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -10,7 +10,11 @@ import { z } from "zod"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; import { lonSchema, latSchema } from "../helpers/schemas.js"; -import { TRAVEL_TIME_MAX_MINUTES, TRAVEL_TIME_PROFILES } from "../gpf/navigation.js"; +import { + TRAVEL_TIME_MAX_MINUTES, + NAVIGATION_PROFILES, + NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, +} from "../gpf/navigation.js"; // --- Shared Constants --- @@ -91,20 +95,37 @@ const intersectsFeatureFilterSchema = z.object({ feature_id: z.string().trim().min(1).describe("Identifiant du feature de référence."), }).strict().describe("Filtre les objets dont la géométrie intersecte celle d'un objet GPF de référence."); -const travelTimeFilterSchema = z.object({ +const navigationProfileSchema = z + .enum(NAVIGATION_PROFILES) + .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."); + +const travelTimeMinutesSchema = z + .number() + .finite() + .positive() + .max(TRAVEL_TIME_MAX_MINUTES) + .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`); + +// Departure point of an isochrone. 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 isochronePointSchema = 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`."), - minutes: z - .number() - .finite() - .positive() - .max(TRAVEL_TIME_MAX_MINUTES) - .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`), - profile: z - .enum(TRAVEL_TIME_PROFILES) - .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."), +}).strict(); + +const travelTimeFilterSchema = isochronePointSchema.extend({ + minutes: travelTimeMinutesSchema, + profile: navigationProfileSchema, }).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); +const isochroneCostValueSchema = z + .number() + .finite() + .positive() + .max(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) + .describe(`Temps de trajet maximal en minutes. Maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); + // --- Shared GPF Inputs --- const gpfTypenameInputSchema = z.object({ @@ -384,14 +405,15 @@ export const gpfGetFeaturesLayerOutputSchema = z.object({ // --- Proxy token discriminant --- // The proxy serves ONE opaque token (in the URL path, `${endpoint}/.json`) -// but two token kinds (a filtered layer query and a single-feature by-id lookup). -// Both producer tools stamp their token +// but several token kinds (a filtered layer query, a single-feature by-id lookup +// and an isochrone). 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. export const PROXY_TOKEN_KIND = { query: "query", byId: "by_id", + isochrone: "isochrone", } as const; export type ProxyTokenKind = (typeof PROXY_TOKEN_KIND)[keyof typeof PROXY_TOKEN_KIND]; @@ -430,6 +452,17 @@ export type GpfGetFeatureByIdLayerInput = z.infer; + +export const gpfIsochroneLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsochroneLayerInputObjectSchema); + // --- `gpf_count_features` --- export const gpfCountFeaturesInputObjectSchema = gpfTypenameInputSchema diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index c96cf1e0..04d8f3f1 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { NavigationIsochroneClient } from "../../src/gpf/navigation.js"; import { RateLimiter } from "../../src/helpers/RateLimiter.js"; +import { ServiceResponseError } from "../../src/helpers/http.js"; describe("NavigationIsochroneClient", () => { it("should build a Valhalla travel-time isochrone request and return its GeoJSON geometry", async () => { @@ -21,7 +22,7 @@ describe("NavigationIsochroneClient", () => { }, ); - const geometry = await client.getTravelTimeGeometry({ + const geometry = await client.getIsochrone({ lon: 2.337306, lat: 48.849319, minutes: 15, @@ -49,11 +50,15 @@ describe("NavigationIsochroneClient", () => { async () => ({ geometry: null }), ); - await expect(client.getTravelTimeGeometry({ + const promise = client.getIsochrone({ lon: 2.337306, lat: 48.849319, minutes: 15, profile: "car", - })).rejects.toThrow("géométrie GeoJSON exploitable"); + }); + + await expect(promise).rejects.toThrow("géométrie GeoJSON exploitable"); + await expect(promise).rejects.toBeInstanceOf(ServiceResponseError); + await expect(promise).rejects.toMatchObject({ httpStatus: 502 }); }); }); diff --git a/test/integration/samples.ts b/test/integration/samples.ts index e85668c5..eaeccf71 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -25,6 +25,7 @@ export const EXPECTED_TOOL_NAMES = [ "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", + "gpf_isochrone_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 11e7c0e3..b686bac3 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, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; +import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsochroneQuery, type WfsClientLike, type TravelTimeResolver } 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"; @@ -412,3 +412,25 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => { await expect(promise).rejects.toThrow(/FeatureCollection GeoJSON exploitable/); }); }); + +describe("proxy/execute · runGeometryIsochroneQuery", () => { + const isochroneInput = { lon: 2.35, lat: 48.85, profile: "pedestrian" as const, minutes: 15 }; + const isochroneGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] }; + + it("returns the isochrone as a FeatureCollection", async () => { + const result = await runGeometryIsochroneQuery(isochroneInput, { + getGeometry: async () => isochroneGeometry, + }); + + expect(result).toEqual({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: isochroneGeometry, + properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", minutes: 15 }, + } + ] + }); + }); +}); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 5eb89af8..77218e88 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -8,17 +8,21 @@ import { resetEnv } from "../../src/config/env"; import { PROXY_TOKEN_KIND } from "../../src/wfs/schema"; import { FeatureNotFoundError, FeatureCardinalityError } from "../../src/wfs/byId"; import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/http"; +import { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation"; // Mock the proxy engine + transport so the server is exercised WITHOUT network. const runGeometryFeatureQuery = vi.fn(); const runGeometryFeatureByIdQuery = vi.fn(); +const runGeometryIsochroneQuery = vi.fn(); vi.mock("../../src/proxy/execute", () => ({ runGeometryFeatureQuery: (...args: unknown[]) => runGeometryFeatureQuery(...args), runGeometryFeatureByIdQuery: (...args: unknown[]) => runGeometryFeatureByIdQuery(...args), + runGeometryIsochroneQuery: (...args: unknown[]) => runGeometryIsochroneQuery(...args), })); vi.mock("../../src/proxy/transport", () => ({ getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveTravelTime: vi.fn() }), getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }), + getDefaultGeometryIsochroneQueryDeps: () => ({ getGeometry: vi.fn() }), })); // A fixed 32-byte hex key for the test environment. @@ -50,6 +54,16 @@ function validByIdToken() { }, KEY); } +function validIsochroneToken() { + return encodeToken({ + kind: PROXY_TOKEN_KIND.isochrone, + lon: 2.35, + lat: 48.85, + profile: "pedestrian", + minutes: 15, + }, KEY); +} + beforeAll(async () => { process.env.TRANSPORT_TYPE = "http"; process.env.PROXY_URL_SECRET = TEST_SECRET; @@ -75,6 +89,7 @@ afterAll(async () => { beforeEach(() => { runGeometryFeatureQuery.mockReset(); runGeometryFeatureByIdQuery.mockReset(); + runGeometryIsochroneQuery.mockReset(); }); describe("proxy/server", () => { @@ -203,6 +218,43 @@ describe("proxy/server", () => { }); }); + it("dispatches an isochrone token to the isochrone engine", async () => { + runGeometryIsochroneQuery.mockResolvedValue(SAMPLE_COLLECTION); + + const res = await request(baseUrl).get(layerPath(validIsochroneToken())); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/geo+json"); + expect(JSON.parse(res.text)).toEqual(SAMPLE_COLLECTION); + expect(runGeometryIsochroneQuery).toHaveBeenCalledOnce(); + expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); + expect(runGeometryFeatureByIdQuery).not.toHaveBeenCalled(); + const [input] = runGeometryIsochroneQuery.mock.calls[0]; + expect(input).toEqual({ + lon: 2.35, + lat: 48.85, + profile: "pedestrian", + minutes: 15, + }); + }); + + it.each([ + ["minutes above the maximum", { minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }], + ["an unknown key", { typename: "BDTOPO_V3:batiment" }], + ])("400 when an isochrone token carries %s", async (_name, overrides) => { + const badToken = encodeToken({ + kind: PROXY_TOKEN_KIND.isochrone, + lon: 2.35, + lat: 48.85, + profile: "pedestrian", + minutes: 15, + ...overrides, + }, KEY); + const res = await request(baseUrl).get(layerPath(badToken)); + expect(res.status).toBe(400); + expect(runGeometryIsochroneQuery).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 102873cf..70a0804c 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -35,7 +35,11 @@ vi.mock("../../src/helpers/RateLimiter", () => ({ }, })); -import { getProxyWfsClient, resolveProxyTravelTimeGeometry } from "../../src/proxy/transport"; +import { + getDefaultGeometryIsochroneQueryDeps, + getProxyWfsClient, + resolveProxyTravelTimeGeometry, +} from "../../src/proxy/transport"; import { resetEnv } from "../../src/config/env"; import { geometryToEwkt } from "../../src/wfs/geometry"; @@ -150,3 +154,29 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { expect(fetchJSONGetWithLimit).not.toHaveBeenCalled(); }); }); + +describe("proxy/transport · getDefaultGeometryIsochroneQueryDeps", () => { + it("resolves the isochrone through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { + const geometry = { type: "Polygon", coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] }; + fetchJSONGetWithLimit.mockResolvedValue({ geometry }); + + const result = await getDefaultGeometryIsochroneQueryDeps().getGeometry({ + lon: 2.35, + lat: 48.85, + minutes: 15, + profile: "car", + }); + + expect(fetchJSONGetWithLimit).toHaveBeenCalledOnce(); + const [url, timeoutMs, maxBytes, label] = fetchJSONGetWithLimit.mock.calls[0]; + expect(url).toContain("data.geopf.fr/navigation/isochrone"); + expect(url).toContain("point=2.35%2C48.85"); + expect(url).toContain("costValue=15"); + expect(url).toContain("profile=car"); + 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'isochrone"); + expect(rateLimit).toHaveBeenCalled(); + expect(result).toEqual(geometry); + }); +}); diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index bcb52e79..40f4d976 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -65,6 +65,7 @@ describe("generate-mcp-docs helpers", () => { { name: "gpf_get_feature_by_id_layer" }, { name: "gpf_count_features" }, { name: "gpf_get_features" }, + { name: "gpf_isochrone_layer" }, { name: "gpf_get_feature_by_id" }, { name: "adminexpress" }, { name: "gpf_get_features_layer" }, @@ -82,6 +83,7 @@ describe("generate-mcp-docs helpers", () => { "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", + "gpf_isochrone_layer", "unknown_custom_tool", ]); }); diff --git a/test/tools/gpf-isochrone-layer.test.ts b/test/tools/gpf-isochrone-layer.test.ts new file mode 100644 index 00000000..32bb14ab --- /dev/null +++ b/test/tools/gpf-isochrone-layer.test.ts @@ -0,0 +1,208 @@ +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 { NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES } from "../../src/gpf/navigation.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema.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: GpfIsochroneLayerTool } = await import( + "../../src/tools/GpfIsochroneLayerTool.js" +); + +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 GpfIsochroneLayerTool", () => { + afterEach(() => { + vi.clearAllMocks(); + mockGetEnv.mockReset(); + }); + + it("publishes the same minutes upper bound as runtime validation", () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const minutesSchema = (tool.toolDefinition.inputSchema.properties as Record) + .minutes as { maximum?: number }; + + expect(minutesSchema.maximum).toBe(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES); + }); + + it("fails fast when no proxy is configured", async () => { + mockGetEnv.mockReturnValue( + makeEnv({ PROXY_URL_SECRET: undefined, PROXY_PUBLIC_BASE_URL: undefined }), + ); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: 15, + }, + }, + }); + + 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 under stdio when a proxy is configured (gate is config, not transport)", async () => { + mockGetEnv.mockReturnValue(makeEnv({ TRANSPORT_TYPE: "stdio" })); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: 15, + }, + }, + }); + + 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 isochrone params", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "car", + minutes: 60, + }, + }, + }); + + 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.isochrone, + lon: 2.337306, + lat: 48.849319, + profile: "car", + minutes: 60, + }); + }); + + it("rejects a time cost above the supported maximum", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, + }, + }, + }); + + 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(`minutes: La valeur doit être au plus ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); + }); + + it("rejects an unknown key such as kind (strict isochrone surface)", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + kind: PROXY_TOKEN_KIND.query, + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + minutes: 15, + }, + }, + }); + + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'kind' n'est pas reconnu."); + }); + + it("rejects a missing profile", async () => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsochroneLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isochrone_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + minutes: 15, + }, + }, + }); + + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'profile' est requis."); + }); +});