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.");
+ });
+});