From 2a6546ecb8394ef96477e19b83ab88ca811f86b3 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 17:07:06 +0200 Subject: [PATCH 1/3] refactor: rename isochrone -> isoline --- README.md | 2 +- docs/config.md | 4 +- docs/dev.md | 2 +- docs/mcp-tools.md | 8 ++-- scripts/generate-mcp-docs.mjs | 2 +- src/gpf/itinerary.ts | 4 +- src/gpf/navigation.ts | 30 +++++++-------- src/proxy/execute.ts | 16 ++++---- src/proxy/server.ts | 14 +++---- src/proxy/transport.ts | 38 +++++++++---------- ...oneLayerTool.ts => GpfIsolineLayerTool.ts} | 28 +++++++------- src/wfs/features.ts | 4 +- src/wfs/schema.ts | 20 +++++----- test/gpf/navigation.test.ts | 14 +++---- test/integration/samples.ts | 2 +- test/proxy/execute.test.ts | 6 +-- test/proxy/server.test.ts | 28 +++++++------- test/proxy/transport.test.ts | 14 +++---- test/scripts/generate-mcp-docs.test.ts | 4 +- ...ayer.test.ts => gpf-isoline-layer.test.ts} | 38 +++++++++---------- 20 files changed, 139 insertions(+), 139 deletions(-) rename src/tools/{GpfIsochroneLayerTool.ts => GpfIsolineLayerTool.ts} (79%) rename test/tools/{gpf-isochrone-layer.test.ts => gpf-isoline-layer.test.ts} (86%) diff --git a/README.md b/README.md index 7e1ed408..9d33aa79 100644 --- a/README.md +++ b/README.md @@ -197,7 +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 | +| Télécharger une isochrone | `gpf_isoline_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 dec5c140..e16eba78 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,11 +29,11 @@ | `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` 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` | +| `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_isoline_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_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). +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). 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 2df58a47..a2ffed73 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_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. +Les tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker. 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 557eb003..43726fc6 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -35,7 +35,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : | `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. | | `destructiveHint` | non | Le tool n'est pas signalé comme destructif. | | `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. | -| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isochrone_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_isoline_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -52,7 +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) +- [`gpf_isoline_layer`](#gpf_isoline_layer) - [`distance`](#distance) ## `geocode` @@ -2215,9 +2215,9 @@ 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` +## `gpf_isoline_layer` -Code Source : [src/tools/GpfIsochroneLayerTool.ts](../src/tools/GpfIsochroneLayerTool.ts) +Code Source : [src/tools/GpfIsolineLayerTool.ts](../src/tools/GpfIsolineLayerTool.ts) ### Titre diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index 202d50d0..cd9e4afc 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -23,7 +23,7 @@ const toolDisplayOrder = [ "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", - "gpf_isochrone_layer", + "gpf_isoline_layer", ]; /** diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index da3d8aae..d3f079d2 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -3,13 +3,13 @@ import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; -import { NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_RESOURCE } from "./navigation.js"; +import { NAVIGATION_PROFILES, NAVIGATION_ISOLINE_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 = NAVIGATION_ISOCHRONE_RESOURCE; +export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE; export const ITINERARY_PROFILES = NAVIGATION_PROFILES; export const ITINERARY_METRICS = ["time", "distance"] as const; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index e74bcb9b..f6c89fd4 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -7,8 +7,8 @@ import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; -export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone"; -export const NAVIGATION_ISOCHRONE_RESOURCE = "bdtopo-valhalla"; +export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone"; +export const NAVIGATION_ISOLINE_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; @@ -16,25 +16,25 @@ export const TRAVEL_TIME_MAX_MINUTES = 120; export type NavigationProfile = typeof NAVIGATION_PROFILES[number]; -export type IsochroneInput = { +export type IsolineInput = { lon: number; lat: number; minutes: number; profile: NavigationProfile; }; -export class NavigationIsochroneClient { +export class NavigationIsolineClient { constructor( private rateLimiter: RateLimiter, private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet, ) {} - async getIsochrone(input: IsochroneInput): Promise { + async getIsoline(input: IsolineInput): Promise { await this.rateLimiter.limit(); - logger.debug(`[gpf:navigation] getIsochrone(${JSON.stringify(input)})...`); + logger.debug(`[gpf:navigation] getIsoline(${JSON.stringify(input)})...`); - const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({ - resource: NAVIGATION_ISOCHRONE_RESOURCE, + const url = `${NAVIGATION_ISOLINE_URL}?${new URLSearchParams({ + resource: NAVIGATION_ISOLINE_RESOURCE, point: `${input.lon},${input.lat}`, direction: "departure", costType: "time", @@ -57,15 +57,15 @@ export class NavigationIsochroneClient { } } -let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined; +let defaultNavigationIsolineClient: NavigationIsolineClient | undefined; -function getDefaultNavigationIsochroneClient() { - defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter()); - return defaultNavigationIsochroneClient; +function getDefaultNavigationIsolineClient() { + defaultNavigationIsolineClient ??= new NavigationIsolineClient(getNavigationRateLimiter()); + return defaultNavigationIsolineClient; } -export const navigationIsochroneClient = { - getIsochrone(input: IsochroneInput) { - return getDefaultNavigationIsochroneClient().getIsochrone(input); +export const navigationIsolineClient = { + getIsoline(input: IsolineInput) { + return getDefaultNavigationIsolineClient().getIsoline(input); }, }; diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index 5e0caf6c..8835235f 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -41,7 +41,7 @@ import type { FeatureCollection, Geometry } from "geojson"; import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput, - GpfIsochroneLayerInput, + GpfIsolineLayerInput, } from "../wfs/schema.js"; // --- Injected Dependencies --- @@ -309,12 +309,12 @@ export async function runGeometryFeatureByIdQuery( // --- Isochrone Public Engine --- -export type IsochroneGeometryResolver = ( - input: GpfIsochroneLayerInput, +export type IsolineGeometryResolver = ( + input: GpfIsolineLayerInput, ) => Promise; -export type GeometryIsochroneQueryDeps = { - getGeometry: IsochroneGeometryResolver; +export type GeometryIsolineQueryDeps = { + getGeometry: IsolineGeometryResolver; }; /** @@ -329,9 +329,9 @@ export type GeometryIsochroneQueryDeps = { * @param deps Injected isochrone geometry resolver. * @returns The isochrone as a single GeoJSON FeatureCollection. */ -export async function runGeometryIsochroneQuery( - input: GpfIsochroneLayerInput, - deps: GeometryIsochroneQueryDeps, +export async function runGeometryIsolineQuery( + input: GpfIsolineLayerInput, + deps: GeometryIsolineQueryDeps, ): Promise { const geometry = await deps.getGeometry(input); diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 87c52847..fc885a0b 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -14,19 +14,19 @@ import { GPF_WFS_URL } from "../wfs/catalog.js"; import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, - gpfIsochroneLayerInputObjectSchema, + gpfIsolineLayerInputObjectSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, - runGeometryIsochroneQuery, + runGeometryIsolineQuery, } from "./execute.js"; import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js"; import { getDefaultGeometryFeatureQueryDeps, getDefaultGeometryFeatureByIdQueryDeps, - getDefaultGeometryIsochroneQueryDeps, + getDefaultGeometryIsolineQueryDeps, } from "./transport.js"; import { decodeToken, @@ -186,11 +186,11 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise fetchJSONGetWithLimit(url, getEnv().PROXY_UPSTREAM_TIMEOUT * 1000, getEnv().PROXY_MAX_RESPONSE_BYTES, "d'isochrone"), ); - return cachedProxyIsochroneClient; + return cachedProxyIsolineClient; } -// --- Reference-geometry resolver (travel_time / isochrone) --- +// --- Reference-geometry resolver (travel_time / isoline) --- /** * Reference-geometry resolver for the `travel_time` spatial filter: turns the - * isochrone into a reference geometry that is fed INTO the WFS query — the + * isoline into a reference geometry that is fed INTO the WFS query — the * sibling of `intersects_feature`'s reference-geometry resolution * (`resolveFeatureGeometry`). It does NOT fetch features itself (that is the - * WFS transport's job). Backed by the proxy isochrone client (bounded fetch + + * WFS transport's job). Backed by the proxy isoline client (bounded fetch + * `GPF_NAVIGATION_PROXY` rate limiter), and injected into `runGeometryFeatureQuery` * so it only fires for travel_time inputs. */ @@ -127,14 +127,14 @@ export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async ( const { operator, ...parameters } = spatialFilter; - return await getProxyIsochroneClient().getIsochrone(parameters); + return await getProxyIsolineClient().getIsoline(parameters); }; // --- Default Engine Dependencies --- /** * Default (production) dependency bundle for `runGeometryFeatureQuery`: the proxy - * WFS client and the proxy isochrone resolver. Bundling the concrete proxy wiring + * WFS client and the proxy isoline resolver. Bundling the concrete proxy wiring * here keeps `server.ts` decoupled from the individual clients — it asks the * transport layer for "the deps" instead of assembling them itself. Tests inject * their own deps into the engine directly. @@ -149,7 +149,7 @@ export function getDefaultGeometryFeatureQueryDeps(): GeometryFeatureQueryDeps { /** * Default (production) dependency bundle for `runGeometryFeatureByIdQuery`. * Narrower than {@link getDefaultGeometryFeatureQueryDeps}: a by-id lookup has no - * spatial filter, so it needs only the WFS client (no isochrone resolver). + * spatial filter, so it needs only the WFS client (no isoline resolver). */ export function getDefaultGeometryFeatureByIdQueryDeps(): GeometryFeatureByIdQueryDeps { return { @@ -158,10 +158,10 @@ export function getDefaultGeometryFeatureByIdQueryDeps(): GeometryFeatureByIdQue } /** - * Default dependency bundle for `runGeometryIsochroneQuery`. + * Default dependency bundle for `runGeometryIsolineQuery`. */ -export function getDefaultGeometryIsochroneQueryDeps(): GeometryIsochroneQueryDeps { +export function getDefaultGeometryIsolineQueryDeps(): GeometryIsolineQueryDeps { return { - getGeometry: (input) => getProxyIsochroneClient().getIsochrone(input), + getGeometry: (input) => getProxyIsolineClient().getIsoline(input), }; } diff --git a/src/tools/GpfIsochroneLayerTool.ts b/src/tools/GpfIsolineLayerTool.ts similarity index 79% rename from src/tools/GpfIsochroneLayerTool.ts rename to src/tools/GpfIsolineLayerTool.ts index b4ea3e1e..26cbca32 100644 --- a/src/tools/GpfIsochroneLayerTool.ts +++ b/src/tools/GpfIsolineLayerTool.ts @@ -18,14 +18,14 @@ import { buildDataUrl } from "../proxy/dataUrl.js"; import { PROXY_TOKEN_KIND, gpfGetFeaturesLayerOutputSchema, - gpfIsochroneLayerInputObjectSchema, - gpfIsochroneLayerPublishedInputSchema, - type GpfIsochroneLayerInput, + gpfIsolineLayerInputObjectSchema, + gpfIsolineLayerPublishedInputSchema, + type GpfIsolineLayerInput, } from "../wfs/schema.js"; import { NAVIGATION_SOURCE } from "../gpf/navigation.js"; import logger from "../logger.js"; -const GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION = [ +const GPF_ISOLINE_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.", @@ -34,16 +34,16 @@ const GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION = [ // --- Tool --- -class GpfIsochroneLayerTool extends BaseTool { - name = "gpf_isochrone_layer"; +class GpfIsolineLayerTool extends BaseTool { + name = "gpf_isoline_layer"; title = "Couche cartographiable d’isochrone GPF"; annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; - description = GPF_ISOCHRONE_LAYER_TOOL_DESCRIPTION; + description = GPF_ISOLINE_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; + schema = gpfIsolineLayerInputObjectSchema; /** * Exposes an input schema variant that stays compatible with most MCP integrations. @@ -51,7 +51,7 @@ class GpfIsochroneLayerTool extends BaseTool { * @returns The published input schema exposed through the MCP tool definition. */ get inputSchema() { - return gpfIsochroneLayerPublishedInputSchema; + return gpfIsolineLayerPublishedInputSchema; } /** @@ -77,23 +77,23 @@ class GpfIsochroneLayerTool extends BaseTool { * @param input Validated isochrone layer input. * @returns The `{ data_url }` payload carrying the opaque token. */ - async execute(input: GpfIsochroneLayerInput) { + async execute(input: GpfIsolineLayerInput) { 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).", + "`gpf_isoline_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); + const tokenParams = gpfIsolineLayerInputObjectSchema.parse(input); logger.info(`[tool] execute ${this.name} ...`, { input: tokenParams, }); const token = encodeToken( - { kind: PROXY_TOKEN_KIND.isochrone, ...tokenParams }, + { kind: PROXY_TOKEN_KIND.isoline, ...tokenParams }, env.PROXY_URL_SECRET, ); @@ -103,4 +103,4 @@ class GpfIsochroneLayerTool extends BaseTool { } } -export default GpfIsochroneLayerTool; +export default GpfIsolineLayerTool; diff --git a/src/wfs/features.ts b/src/wfs/features.ts index 2cbaf697..dea01035 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -6,7 +6,7 @@ * hit counting, and FeatureCollection post-processing. */ -import { navigationIsochroneClient } from "../gpf/navigation.js"; +import { navigationIsolineClient } from "../gpf/navigation.js"; import logger from "../logger.js"; import { resolveFeatureGeometry } from "./referenceGeometry.js"; import { rethrowIdentifiedCatalogDesyncError } from "./catalogDesync.js"; @@ -111,7 +111,7 @@ export async function resolveTravelTimeGeometry( const { operator, ...parameters } = spatialFilter; - return await navigationIsochroneClient.getIsochrone(parameters); + return await navigationIsolineClient.getIsoline(parameters); } /** diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index a33b0d2d..b447f238 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -109,17 +109,17 @@ const travelTimeMinutesSchema = z // 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({ +const isolinePointSchema = z.object({ lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."), lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), }).strict(); -const travelTimeFilterSchema = isochronePointSchema.extend({ +const travelTimeFilterSchema = isolinePointSchema.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 +const isolineCostValueSchema = z .number() .finite() .positive() @@ -406,14 +406,14 @@ export const gpfGetFeaturesLayerOutputSchema = z.object({ // The proxy serves ONE opaque token (in the URL path, `${endpoint}/.json`) // but several token kinds (a filtered layer query, a single-feature by-id lookup -// and an isochrone). Every producer tool stamps its token +// and an isoline). 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", + isoline: "isoline", } as const; export type ProxyTokenKind = (typeof PROXY_TOKEN_KIND)[keyof typeof PROXY_TOKEN_KIND]; @@ -452,16 +452,16 @@ export type GpfGetFeatureByIdLayerInput = z.infer; +export type GpfIsolineLayerInput = z.infer; -export const gpfIsochroneLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsochroneLayerInputObjectSchema); +export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema); // --- `gpf_count_features` --- diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index 04d8f3f1..5856bfea 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { NavigationIsochroneClient } from "../../src/gpf/navigation.js"; +import { NavigationIsolineClient } 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 () => { +describe("NavigationIsolineClient", () => { + it("should build a Valhalla travel-time isoline request and return its GeoJSON geometry", async () => { const urls: string[] = []; - const client = new NavigationIsochroneClient( + const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), async (url) => { urls.push(url); @@ -22,7 +22,7 @@ describe("NavigationIsochroneClient", () => { }, ); - const geometry = await client.getIsochrone({ + const geometry = await client.getIsoline({ lon: 2.337306, lat: 48.849319, minutes: 15, @@ -45,12 +45,12 @@ describe("NavigationIsochroneClient", () => { }); it("should reject responses without usable GeoJSON geometry", async () => { - const client = new NavigationIsochroneClient( + const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), async () => ({ geometry: null }), ); - const promise = client.getIsochrone({ + const promise = client.getIsoline({ lon: 2.337306, lat: 48.849319, minutes: 15, diff --git a/test/integration/samples.ts b/test/integration/samples.ts index eaeccf71..fa89d23a 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -25,7 +25,7 @@ export const EXPECTED_TOOL_NAMES = [ "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", - "gpf_isochrone_layer", + "gpf_isoline_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 b686bac3..d4be0db4 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, runGeometryIsochroneQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute"; +import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, 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"; @@ -413,12 +413,12 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => { }); }); -describe("proxy/execute · runGeometryIsochroneQuery", () => { +describe("proxy/execute · runGeometryIsolineQuery", () => { 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, { + const result = await runGeometryIsolineQuery(isochroneInput, { getGeometry: async () => isochroneGeometry, }); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 77218e88..0e8141e3 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -13,16 +13,16 @@ 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(); +const runGeometryIsolineQuery = vi.fn(); vi.mock("../../src/proxy/execute", () => ({ runGeometryFeatureQuery: (...args: unknown[]) => runGeometryFeatureQuery(...args), runGeometryFeatureByIdQuery: (...args: unknown[]) => runGeometryFeatureByIdQuery(...args), - runGeometryIsochroneQuery: (...args: unknown[]) => runGeometryIsochroneQuery(...args), + runGeometryIsolineQuery: (...args: unknown[]) => runGeometryIsolineQuery(...args), })); vi.mock("../../src/proxy/transport", () => ({ getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveTravelTime: vi.fn() }), getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }), - getDefaultGeometryIsochroneQueryDeps: () => ({ getGeometry: vi.fn() }), + getDefaultGeometryIsolineQueryDeps: () => ({ getGeometry: vi.fn() }), })); // A fixed 32-byte hex key for the test environment. @@ -54,9 +54,9 @@ function validByIdToken() { }, KEY); } -function validIsochroneToken() { +function validIsolineToken() { return encodeToken({ - kind: PROXY_TOKEN_KIND.isochrone, + kind: PROXY_TOKEN_KIND.isoline, lon: 2.35, lat: 48.85, profile: "pedestrian", @@ -89,7 +89,7 @@ afterAll(async () => { beforeEach(() => { runGeometryFeatureQuery.mockReset(); runGeometryFeatureByIdQuery.mockReset(); - runGeometryIsochroneQuery.mockReset(); + runGeometryIsolineQuery.mockReset(); }); describe("proxy/server", () => { @@ -218,18 +218,18 @@ describe("proxy/server", () => { }); }); - it("dispatches an isochrone token to the isochrone engine", async () => { - runGeometryIsochroneQuery.mockResolvedValue(SAMPLE_COLLECTION); + it("dispatches an isoline token to the isoline engine", async () => { + runGeometryIsolineQuery.mockResolvedValue(SAMPLE_COLLECTION); - const res = await request(baseUrl).get(layerPath(validIsochroneToken())); + const res = await request(baseUrl).get(layerPath(validIsolineToken())); 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(runGeometryIsolineQuery).toHaveBeenCalledOnce(); expect(runGeometryFeatureQuery).not.toHaveBeenCalled(); expect(runGeometryFeatureByIdQuery).not.toHaveBeenCalled(); - const [input] = runGeometryIsochroneQuery.mock.calls[0]; + const [input] = runGeometryIsolineQuery.mock.calls[0]; expect(input).toEqual({ lon: 2.35, lat: 48.85, @@ -241,9 +241,9 @@ describe("proxy/server", () => { 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) => { + ])("400 when an isoline token carries %s", async (_name, overrides) => { const badToken = encodeToken({ - kind: PROXY_TOKEN_KIND.isochrone, + kind: PROXY_TOKEN_KIND.isoline, lon: 2.35, lat: 48.85, profile: "pedestrian", @@ -252,7 +252,7 @@ describe("proxy/server", () => { }, KEY); const res = await request(baseUrl).get(layerPath(badToken)); expect(res.status).toBe(400); - expect(runGeometryIsochroneQuery).not.toHaveBeenCalled(); + expect(runGeometryIsolineQuery).not.toHaveBeenCalled(); }); it("404 when the by-id feature is absent (FeatureNotFoundError)", async () => { diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index 70a0804c..a0e2bfac 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -7,8 +7,8 @@ import type { GpfGetFeaturesInput } from "../../src/wfs/schema"; // - fetchJSONPostWithLimit (the bounded WFS fetch, parses to JSON) — but keep the real error classes; // - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the travel_time leg // goes through the SAME PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES bounds as WFS. -// The real NavigationIsochroneClient runs (only its fetcher is mocked), so this covers -// the previously-untested gap where the isochrone leg used unbounded fetchJSONGet. +// The real NavigationIsolineClient runs (only its fetcher is mocked), so this covers +// the previously-untested gap where the isoline leg used unbounded fetchJSONGet. // - RateLimiter (assert it is invoked, without real timing). // The parse + 502-on-bad-body now lives inside fetchJSON*WithLimit (helpers/http), // so it is covered there; here we only assert the transport wires the right args. @@ -36,7 +36,7 @@ vi.mock("../../src/helpers/RateLimiter", () => ({ })); import { - getDefaultGeometryIsochroneQueryDeps, + getDefaultGeometryIsolineQueryDeps, getProxyWfsClient, resolveProxyTravelTimeGeometry, } from "../../src/proxy/transport"; @@ -118,7 +118,7 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { }; it("resolves the isochrone through the BOUNDED fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES) and returns EWKT", async () => { - // The real NavigationIsochroneClient runs; only its fetcher is mocked. This is + // The real NavigationIsolineClient runs; only its fetcher is mocked. This is // the regression guard: the travel_time leg must NOT use the unbounded // fetchJSONGet (HTTP_TIMEOUT only) — it must go through fetchJSONGetWithLimit // with the SAME bounds as the WFS leg, so a 2-call travel_time stays capped. @@ -155,12 +155,12 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { }); }); -describe("proxy/transport · getDefaultGeometryIsochroneQueryDeps", () => { - it("resolves the isochrone through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { +describe("proxy/transport · getDefaultGeometryIsolineQueryDeps", () => { + it("resolves the isoline 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({ + const result = await getDefaultGeometryIsolineQueryDeps().getGeometry({ lon: 2.35, lat: 48.85, minutes: 15, diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index 40f4d976..1bdbc168 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -65,7 +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_isoline_layer" }, { name: "gpf_get_feature_by_id" }, { name: "adminexpress" }, { name: "gpf_get_features_layer" }, @@ -83,7 +83,7 @@ describe("generate-mcp-docs helpers", () => { "gpf_count_features", "gpf_get_feature_by_id", "gpf_get_feature_by_id_layer", - "gpf_isochrone_layer", + "gpf_isoline_layer", "unknown_custom_tool", ]); }); diff --git a/test/tools/gpf-isochrone-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts similarity index 86% rename from test/tools/gpf-isochrone-layer.test.ts rename to test/tools/gpf-isoline-layer.test.ts index 32bb14ab..fb27a96a 100644 --- a/test/tools/gpf-isochrone-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -22,8 +22,8 @@ vi.doMock("../../src/config/env.js", async () => { }; }); -const { default: GpfIsochroneLayerTool } = await import( - "../../src/tools/GpfIsochroneLayerTool.js" +const { default: GpfIsolineLayerTool } = await import( + "../../src/tools/GpfIsolineLayerTool.js" ); function makeEnv(overrides: Partial): Env { @@ -36,7 +36,7 @@ function makeEnv(overrides: Partial): Env { } as Env; } -describe("Test GpfIsochroneLayerTool", () => { +describe("Test GpfIsolineLayerTool", () => { afterEach(() => { vi.clearAllMocks(); mockGetEnv.mockReset(); @@ -44,7 +44,7 @@ describe("Test GpfIsochroneLayerTool", () => { it("publishes the same minutes upper bound as runtime validation", () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const minutesSchema = (tool.toolDefinition.inputSchema.properties as Record) .minutes as { maximum?: number }; @@ -56,11 +56,11 @@ describe("Test GpfIsochroneLayerTool", () => { mockGetEnv.mockReturnValue( makeEnv({ PROXY_URL_SECRET: undefined, PROXY_PUBLIC_BASE_URL: undefined }), ); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -80,11 +80,11 @@ describe("Test GpfIsochroneLayerTool", () => { 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 tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -99,13 +99,13 @@ describe("Test GpfIsochroneLayerTool", () => { 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 () => { + it("builds an opaque data_url that round-trips to the tagged isoline params", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -133,7 +133,7 @@ describe("Test GpfIsochroneLayerTool", () => { const token = url.pathname.slice("/api/v1/proxy/".length, -".json".length); const decoded = decodeToken(token, SECRET); expect(decoded).toEqual({ - kind: PROXY_TOKEN_KIND.isochrone, + kind: PROXY_TOKEN_KIND.isoline, lon: 2.337306, lat: 48.849319, profile: "car", @@ -143,11 +143,11 @@ describe("Test GpfIsochroneLayerTool", () => { it("rejects a time cost above the supported maximum", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, @@ -166,13 +166,13 @@ describe("Test GpfIsochroneLayerTool", () => { 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 () => { + it("rejects an unknown key such as kind (strict isoline surface)", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { kind: PROXY_TOKEN_KIND.query, lon: 2.337306, @@ -189,11 +189,11 @@ describe("Test GpfIsochroneLayerTool", () => { it("rejects a missing profile", async () => { mockGetEnv.mockReturnValue(makeEnv({})); - const tool = new GpfIsochroneLayerTool(); + const tool = new GpfIsolineLayerTool(); const response = await tool.toolCall({ params: { - name: "gpf_isochrone_layer", + name: "gpf_isoline_layer", arguments: { lon: 2.337306, lat: 48.849319, From c7ebb4c251ba023653b5634e8c47271806c3e7bc Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 17:10:20 +0200 Subject: [PATCH 2/3] feat: add isodistance capability to GpfIsolineLayer --- README.md | 2 +- docs/mcp-tools.md | 46 +++++++++++++++---------- src/gpf/itinerary.ts | 4 +-- src/gpf/navigation.ts | 12 ++++--- src/proxy/execute.ts | 12 +++---- src/proxy/server.ts | 4 +-- src/proxy/transport.ts | 8 +++-- src/tools/GpfIsolineLayerTool.ts | 20 ++++++----- src/wfs/features.ts | 8 +++-- src/wfs/schema.ts | 34 +++++++++++++++---- test/gpf/navigation.test.ts | 46 +++++++++++++++++++++++-- test/proxy/execute.test.ts | 17 ++++++---- test/proxy/server.test.ts | 11 +++--- test/proxy/transport.test.ts | 3 +- test/tools/gpf-isoline-layer.test.ts | 51 ++++++++++++++++++++++------ 15 files changed, 202 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 9d33aa79..2da189dc 100644 --- a/README.md +++ b/README.md @@ -197,7 +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_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 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 | ## Architecture en bref diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 43726fc6..0254ef94 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1320,7 +1320,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." } }, "required": [ @@ -1645,7 +1645,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." } }, "required": [ @@ -1972,7 +1972,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "car", "pedestrian" ], - "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." } }, "required": [ @@ -2221,25 +2221,27 @@ Code Source : [src/tools/GpfIsolineLayerTool.ts](../src/tools/GpfIsolineLayerToo ### Titre -Couche cartographiable d’isochrone GPF +Couche cartographiable d’isochrone / d'isodistance 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. +Interroge l'isochrone ou l'isodistance autour d'un point : isochrone si `cost_type = "time"`, isodistance si `cost_type = "distance"`. À 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)). +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 `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement, `cost_type` pour choisir le type de calcul et `cost_value` pour fixer le seuil maximal (en minutes si `time`, en mètres si `distance`). +(source : Géoplateforme (calcul d'isochrone / d'isodistance)). ``` ### Schéma d’entrée | Champ | Type | Requis | Description | | --- | --- | --- | --- | +| `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. | +| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"`. | | `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. | +| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. |
Schéma d’entrée brut @@ -2260,26 +2262,34 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac "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`)." + "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`." + }, + "cost_type": { + "type": "string", + "enum": [ + "time", + "distance" + ], + "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance." + }, + "cost_value": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"`." } }, "required": [ "lon", "lat", - "minutes", - "profile" + "profile", + "cost_type", + "cost_value" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts index d3f079d2..8ad26a4e 100644 --- a/src/gpf/itinerary.ts +++ b/src/gpf/itinerary.ts @@ -3,7 +3,7 @@ import logger from "../logger.js"; import type { JsonFetcher } from "../helpers/http.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; -import { NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE } from "./navigation.js"; +import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_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"; @@ -11,7 +11,7 @@ export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itiner // same travel times. export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE; export const ITINERARY_PROFILES = NAVIGATION_PROFILES; -export const ITINERARY_METRICS = ["time", "distance"] as const; +export const ITINERARY_METRICS = NAVIGATION_METRICS; export type ItineraryProfile = typeof ITINERARY_PROFILES[number]; export type ItineraryMetric = typeof ITINERARY_METRICS[number]; diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index f6c89fd4..6091596d 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -6,20 +6,24 @@ import { isGeometryLike } from "../helpers/geojson.js"; import type { RateLimiter } from "../helpers/RateLimiter.js"; import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; -export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)"; +export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)"; export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone"; export const NAVIGATION_ISOLINE_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 NAVIGATION_METRICS = ["time", "distance"] as const; + export const TRAVEL_TIME_MAX_MINUTES = 120; export type NavigationProfile = typeof NAVIGATION_PROFILES[number]; +export type NavigationMetric = typeof NAVIGATION_METRICS[number]; export type IsolineInput = { lon: number; lat: number; - minutes: number; + cost_type: NavigationMetric; + cost_value: number; profile: NavigationProfile; }; @@ -37,8 +41,8 @@ export class NavigationIsolineClient { resource: NAVIGATION_ISOLINE_RESOURCE, point: `${input.lon},${input.lat}`, direction: "departure", - costType: "time", - costValue: String(input.minutes), + costType: input.cost_type, + costValue: String(input.cost_value), profile: input.profile, timeUnit: "minute", distanceUnit: "meter", diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts index 8835235f..d71bc807 100644 --- a/src/proxy/execute.ts +++ b/src/proxy/execute.ts @@ -318,16 +318,16 @@ export type GeometryIsolineQueryDeps = { }; /** - * Resolves an isochrone and returns it as a GeoJSON `FeatureCollection` with full - * geometry (for map rendering by MCP Carto). + * Resolves an isoline (isochrone or isodistance) 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. + * Counterpart of {@link runGeometryFeatureQuery} for the isoline 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. + * @param input Validated isoline layer input (`{ lon, lat, profile, cost_type, cost_value }`). + * @param deps Injected isoline geometry resolver. + * @returns The isoline as a GeoJSON FeatureCollection. */ export async function runGeometryIsolineQuery( input: GpfIsolineLayerInput, diff --git a/src/proxy/server.ts b/src/proxy/server.ts index fc885a0b..0af7d89d 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -14,7 +14,7 @@ import { GPF_WFS_URL } from "../wfs/catalog.js"; import { gpfGetFeaturesLayerInputSchema, gpfGetFeatureByIdLayerInputObjectSchema, - gpfIsolineLayerInputObjectSchema, + gpfIsolineLayerInputSchema, PROXY_TOKEN_KIND, } from "../wfs/schema.js"; import { @@ -187,7 +187,7 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise { name = "gpf_isoline_layer"; - title = "Couche cartographiable d’isochrone GPF"; + title = "Couche cartographiable d’isochrone / d'isodistance GPF"; annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; description = GPF_ISOLINE_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. schema = gpfIsolineLayerInputObjectSchema; /** @@ -70,11 +72,11 @@ class GpfIsolineLayerTool extends BaseTool { } /** - * 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` + * Mints the opaque proxy URL for the requested isoline. No upstream call is + * made here: the isoline itself is computed by the proxy when the `data_url` * is fetched. * - * @param input Validated isochrone layer input. + * @param input Validated isoline layer input. * @returns The `{ data_url }` payload carrying the opaque token. */ async execute(input: GpfIsolineLayerInput) { @@ -86,7 +88,7 @@ class GpfIsolineLayerTool extends BaseTool { ); } - const tokenParams = gpfIsolineLayerInputObjectSchema.parse(input); + const tokenParams = gpfIsolineLayerInputSchema.parse(input); logger.info(`[tool] execute ${this.name} ...`, { input: tokenParams, diff --git a/src/wfs/features.ts b/src/wfs/features.ts index dea01035..559a85bb 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -109,9 +109,13 @@ export async function resolveTravelTimeGeometry( return undefined; } - const { operator, ...parameters } = spatialFilter; + const { operator, minutes, ...parameters } = spatialFilter; - return await navigationIsolineClient.getIsoline(parameters); + return await navigationIsolineClient.getIsoline({ + ...parameters, + cost_type: "time", + cost_value: minutes, + }); } /** diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index b447f238..1446aca0 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -11,9 +11,11 @@ import { z } from "zod"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; import { lonSchema, latSchema } from "../helpers/schemas.js"; import { - TRAVEL_TIME_MAX_MINUTES, + NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + type NavigationMetric, + TRAVEL_TIME_MAX_MINUTES, } from "../gpf/navigation.js"; // --- Shared Constants --- @@ -97,7 +99,7 @@ const intersectsFeatureFilterSchema = z.object({ const navigationProfileSchema = z .enum(NAVIGATION_PROFILES) - .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."); + .describe("Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."); const travelTimeMinutesSchema = z .number() @@ -119,12 +121,28 @@ const travelTimeFilterSchema = isolinePointSchema.extend({ profile: navigationProfileSchema, }).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); +const navigationMetricsSchema = z + .enum(NAVIGATION_METRICS) + .describe("Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."); + const isolineCostValueSchema = z .number() .finite() .positive() - .max(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) - .describe(`Temps de trajet maximal en minutes. Maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}.`); + .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}), et en mètres si \`cost_type = \"distance\"\`.`); + +function assertIsolineCostValue(input: { cost_type: NavigationMetric; cost_value: number }, ctx: z.RefinementCtx) { + if (input.cost_type === "time" && input.cost_value > NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + type: "number", + inclusive: true, + path: ["cost_value"], + message: `Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`, + }); + } +} // --- Shared GPF Inputs --- @@ -455,11 +473,15 @@ export const gpfGetFeatureByIdLayerPublishedInputSchema = generatePublishedInput // --- `gpf_isoline_layer` (proxy) --- export const gpfIsolineLayerInputObjectSchema = isolinePointSchema.extend({ - minutes: isolineCostValueSchema, profile: navigationProfileSchema, + cost_type: navigationMetricsSchema, + cost_value: isolineCostValueSchema, }).strict(); -export type GpfIsolineLayerInput = z.infer; +export const gpfIsolineLayerInputSchema = gpfIsolineLayerInputObjectSchema + .superRefine(assertIsolineCostValue); + +export type GpfIsolineLayerInput = z.infer; export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema); diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts index 5856bfea..8c3bfb0b 100644 --- a/test/gpf/navigation.test.ts +++ b/test/gpf/navigation.test.ts @@ -25,7 +25,8 @@ describe("NavigationIsolineClient", () => { const geometry = await client.getIsoline({ lon: 2.337306, lat: 48.849319, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "pedestrian", }); @@ -44,6 +45,46 @@ describe("NavigationIsolineClient", () => { expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("geojson"); }); + it("should build an isodistance request and return its GeoJSON geometry", async () => { + const urls: string[] = []; + const client = new NavigationIsolineClient( + new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), + async (url) => { + urls.push(url); + return { + geometry: { + type: "Polygon", + coordinates: [ + [[2.338306, 48.849753], [2.337306, 48.850142], [2.338306, 48.849753]], + ], + }, + }; + }, + ); + + const geometry = await client.getIsoline({ + lon: 2.337306, + lat: 48.849319, + cost_type: "distance", + cost_value: 1200, + profile: "car", + }); + + expect(geometry.type).toEqual("Polygon"); + const parsedUrl = new URL(urls[0]); + expect(parsedUrl.origin + parsedUrl.pathname).toEqual("https://data.geopf.fr/navigation/isochrone"); + expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-valhalla"); + expect(parsedUrl.searchParams.get("point")).toEqual("2.337306,48.849319"); + expect(parsedUrl.searchParams.get("direction")).toEqual("departure"); + expect(parsedUrl.searchParams.get("costType")).toEqual("distance"); + expect(parsedUrl.searchParams.get("costValue")).toEqual("1200"); + expect(parsedUrl.searchParams.get("profile")).toEqual("car"); + expect(parsedUrl.searchParams.get("timeUnit")).toEqual("minute"); + expect(parsedUrl.searchParams.get("distanceUnit")).toEqual("meter"); + expect(parsedUrl.searchParams.get("crs")).toEqual("EPSG:4326"); + expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("geojson"); + }); + it("should reject responses without usable GeoJSON geometry", async () => { const client = new NavigationIsolineClient( new RateLimiter({ name: "test", maxCalls: 100, period: 1 }), @@ -53,7 +94,8 @@ describe("NavigationIsolineClient", () => { const promise = client.getIsoline({ lon: 2.337306, lat: 48.849319, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "car", }); diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts index d4be0db4..7a2c0290 100644 --- a/test/proxy/execute.test.ts +++ b/test/proxy/execute.test.ts @@ -414,12 +414,15 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => { }); describe("proxy/execute · runGeometryIsolineQuery", () => { - 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]]] }; + const isolineInput = { + lon: 2.35, lat: 48.85, profile: "pedestrian" as const, + cost_type: "time" as const, cost_value: 15, + }; + const isolineGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] }; - it("returns the isochrone as a FeatureCollection", async () => { - const result = await runGeometryIsolineQuery(isochroneInput, { - getGeometry: async () => isochroneGeometry, + it("returns the isoline as a FeatureCollection", async () => { + const result = await runGeometryIsolineQuery(isolineInput, { + getGeometry: async () => isolineGeometry, }); expect(result).toEqual({ @@ -427,8 +430,8 @@ describe("proxy/execute · runGeometryIsolineQuery", () => { features: [ { type: "Feature", - geometry: isochroneGeometry, - properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", minutes: 15 }, + geometry: isolineGeometry, + properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 }, } ] }); diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 0e8141e3..d47f5df2 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -60,7 +60,8 @@ function validIsolineToken() { lon: 2.35, lat: 48.85, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }, KEY); } @@ -234,12 +235,13 @@ describe("proxy/server", () => { lon: 2.35, lat: 48.85, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }); }); it.each([ - ["minutes above the maximum", { minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }], + ["minutes above the maximum", { cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }], ["an unknown key", { typename: "BDTOPO_V3:batiment" }], ])("400 when an isoline token carries %s", async (_name, overrides) => { const badToken = encodeToken({ @@ -247,7 +249,8 @@ describe("proxy/server", () => { lon: 2.35, lat: 48.85, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, ...overrides, }, KEY); const res = await request(baseUrl).get(layerPath(badToken)); diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index a0e2bfac..2aef5981 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -163,7 +163,8 @@ describe("proxy/transport · getDefaultGeometryIsolineQueryDeps", () => { const result = await getDefaultGeometryIsolineQueryDeps().getGeometry({ lon: 2.35, lat: 48.85, - minutes: 15, + cost_type: "time", + cost_value: 15, profile: "car", }); diff --git a/test/tools/gpf-isoline-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts index fb27a96a..7b2c8186 100644 --- a/test/tools/gpf-isoline-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -42,14 +42,40 @@ describe("Test GpfIsolineLayerTool", () => { mockGetEnv.mockReset(); }); - it("publishes the same minutes upper bound as runtime validation", () => { + it("publishes the time-only cost limit without capping distance costs", () => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); - const minutesSchema = (tool.toolDefinition.inputSchema.properties as Record) - .minutes as { maximum?: number }; + const properties = tool.toolDefinition.inputSchema.properties as Record; + const costValueSchema = properties.cost_value as { description?: string; maximum?: number }; - expect(minutesSchema.maximum).toBe(NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES); + expect(properties).not.toHaveProperty("minutes"); + expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES})`); + expect(costValueSchema.maximum).toBeUndefined(); + }); + + it.each([ + { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES }, + { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }, + ])("accepts $cost_type cost at $cost_value", async ({ cost_type, cost_value }) => { + mockGetEnv.mockReturnValue(makeEnv({})); + const tool = new GpfIsolineLayerTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_isoline_layer", + arguments: { + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + cost_type, + cost_value, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(response.structuredContent).toEqual({ data_url: expect.any(String) }); }); it("fails fast when no proxy is configured", async () => { @@ -65,7 +91,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }, }, }); @@ -89,7 +116,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - minutes: 15, + cost_type: "time", + cost_value: 15, }, }, }); @@ -110,7 +138,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "car", - minutes: 60, + cost_type: "distance", + cost_value: 1200, }, }, }); @@ -137,7 +166,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "car", - minutes: 60, + cost_type: "distance", + cost_value: 1200, }); }); @@ -152,7 +182,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - minutes: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, + cost_type: "time", + cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, }, }, }); @@ -163,7 +194,7 @@ describe("Test GpfIsolineLayerTool", () => { 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}.`); + expect(textContent.text).toContain(`cost_value: Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`); }); it("rejects an unknown key such as kind (strict isoline surface)", async () => { From c962882917ba2510bf6d5f881ede00d24c51140d Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 20 Aug 2026 14:44:55 +0200 Subject: [PATCH 3/3] feat: add isodistance maximum distance constraint --- docs/mcp-tools.md | 4 +- src/gpf/navigation.ts | 5 ++- src/wfs/schema.ts | 20 +++++++--- test/proxy/server.test.ts | 5 ++- test/tools/gpf-isoline-layer.test.ts | 57 +++++++++++++++++++++------- 5 files changed, 67 insertions(+), 24 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 0254ef94..1baf6e89 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -2238,7 +2238,7 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. | -| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"`. | +| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"` (maximum : 50000). | | `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. | | `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. | | `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. | @@ -2281,7 +2281,7 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac "cost_value": { "type": "number", "exclusiveMinimum": 0, - "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"`." + "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"` (maximum : 50000)." } }, "required": [ diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts index 6091596d..d95b50a8 100644 --- a/src/gpf/navigation.ts +++ b/src/gpf/navigation.ts @@ -9,8 +9,9 @@ import { getNavigationRateLimiter } from "./navigationRateLimiter.js"; export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)"; export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone"; export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla"; -// Upstream ceiling accepted by the GPF isochrone service for a time cost. -export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600; +// Upstream ceilings accepted by the GPF isochrone service, per cost type. +export const NAVIGATION_ISOCHRONE_MAX_MINUTES = 600; +export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000; export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const; export const NAVIGATION_METRICS = ["time", "distance"] as const; diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 1446aca0..49b8b015 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -13,7 +13,8 @@ import { lonSchema, latSchema } from "../helpers/schemas.js"; import { NAVIGATION_METRICS, NAVIGATION_PROFILES, - NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + NAVIGATION_ISOCHRONE_MAX_MINUTES, + NAVIGATION_ISODISTANCE_MAX_METERS, type NavigationMetric, TRAVEL_TIME_MAX_MINUTES, } from "../gpf/navigation.js"; @@ -129,17 +130,26 @@ const isolineCostValueSchema = z .number() .finite() .positive() - .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES}), et en mètres si \`cost_type = \"distance\"\`.`); + .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES}), et en mètres si \`cost_type = \"distance\"\` (maximum : ${NAVIGATION_ISODISTANCE_MAX_METERS}).`); + +// One max per cost type: `cost_value` is minutes for `time` and meters for +// `distance`, so the ceiling can only be checked once `cost_type` is known. +const ISOLINE_COST_LIMITS: Record = { + time: { max: NAVIGATION_ISOCHRONE_MAX_MINUTES, name: "temps", unit: "minutes" }, + distance: { max: NAVIGATION_ISODISTANCE_MAX_METERS, name: "distance", unit: "mètres" }, +}; function assertIsolineCostValue(input: { cost_type: NavigationMetric; cost_value: number }, ctx: z.RefinementCtx) { - if (input.cost_type === "time" && input.cost_value > NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES) { + const { max, name, unit } = ISOLINE_COST_LIMITS[input.cost_type]; + + if (input.cost_value > max) { ctx.addIssue({ code: z.ZodIssueCode.too_big, - maximum: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES, + maximum: max, type: "number", inclusive: true, path: ["cost_value"], - message: `Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`, + message: `Le coût maximal en ${name} ne peut pas dépasser ${max} ${unit}.`, }); } } diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index d47f5df2..41b19b03 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -8,7 +8,7 @@ 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"; +import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation"; // Mock the proxy engine + transport so the server is exercised WITHOUT network. const runGeometryFeatureQuery = vi.fn(); @@ -241,7 +241,8 @@ describe("proxy/server", () => { }); it.each([ - ["minutes above the maximum", { cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }], + ["minutes above the maximum", { cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 }], + ["distance above the maximum", { cost_type: "distance", cost_value: NAVIGATION_ISODISTANCE_MAX_METERS + 1 }], ["an unknown key", { typename: "BDTOPO_V3:batiment" }], ])("400 when an isoline token carries %s", async (_name, overrides) => { const badToken = encodeToken({ diff --git a/test/tools/gpf-isoline-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts index 7b2c8186..d726952d 100644 --- a/test/tools/gpf-isoline-layer.test.ts +++ b/test/tools/gpf-isoline-layer.test.ts @@ -2,9 +2,9 @@ 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"; +import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation.js"; +import { PROXY_TOKEN_KIND, gpfIsolineLayerInputSchema } from "../../src/wfs/schema.js"; +import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema"; const SECRET_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const SECRET = Buffer.from(SECRET_HEX, "hex"); @@ -42,7 +42,7 @@ describe("Test GpfIsolineLayerTool", () => { mockGetEnv.mockReset(); }); - it("publishes the time-only cost limit without capping distance costs", () => { + it("publishes the time and distance cost limits", () => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); @@ -50,13 +50,14 @@ describe("Test GpfIsolineLayerTool", () => { const costValueSchema = properties.cost_value as { description?: string; maximum?: number }; expect(properties).not.toHaveProperty("minutes"); - expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES})`); + expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES})`); + expect(costValueSchema.description).toContain(`\`cost_type = "distance"\` (maximum : ${NAVIGATION_ISODISTANCE_MAX_METERS})`); expect(costValueSchema.maximum).toBeUndefined(); }); it.each([ - { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES }, - { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1 }, + { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES }, + { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 }, ])("accepts $cost_type cost at $cost_value", async ({ cost_type, cost_value }) => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); @@ -139,7 +140,7 @@ describe("Test GpfIsolineLayerTool", () => { lat: 48.849319, profile: "car", cost_type: "distance", - cost_value: 1200, + cost_value: 50_000, }, }, }); @@ -167,11 +168,14 @@ describe("Test GpfIsolineLayerTool", () => { lat: 48.849319, profile: "car", cost_type: "distance", - cost_value: 1200, + cost_value: 50_000, }); }); - it("rejects a time cost above the supported maximum", async () => { + it.each([ + { type: "time", max: NAVIGATION_ISOCHRONE_MAX_MINUTES }, + { type: "distance", max: NAVIGATION_ISODISTANCE_MAX_METERS }, + ])("rejects a $type cost above the supported maximum ($cost)", async ({ type, max }) => { mockGetEnv.mockReturnValue(makeEnv({})); const tool = new GpfIsolineLayerTool(); @@ -182,8 +186,8 @@ describe("Test GpfIsolineLayerTool", () => { lon: 2.337306, lat: 48.849319, profile: "pedestrian", - cost_type: "time", - cost_value: NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES + 1, + cost_type: type, + cost_value: max + 1, }, }, }); @@ -194,7 +198,9 @@ describe("Test GpfIsolineLayerTool", () => { if (textContent.type !== "text") { throw new Error("expected text content"); } - expect(textContent.text).toContain(`cost_value: Le coût maximal en temps ne peut pas dépasser ${NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES} minutes.`); + const nom = type === "time" ? "temps" : type; + const units = type === "time" ? "minutes" : "mètres"; + expect(textContent.text).toContain(`cost_value: Le coût maximal en ${nom} ne peut pas dépasser ${max} ${units}.`); }); it("rejects an unknown key such as kind (strict isoline surface)", async () => { @@ -236,4 +242,29 @@ describe("Test GpfIsolineLayerTool", () => { expect(response.isError).toBe(true); expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'profile' est requis."); }); + + it("emits a `too_big` cost_value issue for an out-of-range distance", () => { + const result = gpfIsolineLayerInputSchema.safeParse({ + lon: 2.337306, + lat: 48.849319, + profile: "pedestrian", + cost_type: "distance", + cost_value: NAVIGATION_ISODISTANCE_MAX_METERS+1, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("expected parse failure"); + } + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "too_big", + path: ["cost_value"], + message: expect.stringContaining("ne peut pas dépasser"), + }), + ]), + ); + }); });