From 1e4e7b253fee3aa0e8a91c0de6bd000c7d1a8b02 Mon Sep 17 00:00:00 2001
From: Charles-Pierre Astolfi <350757+cpa@users.noreply.github.com>
Date: Wed, 27 May 2026 22:22:30 +0200
Subject: [PATCH] Add isochrone tool
---
README.md | 3 +-
docs/mcp-tools.md | 175 +++++++++
src/gpf/isochrone.ts | 281 ++++++++++++++
src/tools/IsochroneTool.ts | 178 +++++++++
test/gpf/isochrone.test.ts | 202 ++++++++++
.../level1-protocol/isochrone.test.ts | 98 +++++
.../level2-agent/level2-agent.test.ts | 14 +
test/integration/samples.ts | 1 +
test/tools/isochrone.test.ts | 365 ++++++++++++++++++
test/tools/strict-input.test.ts | 11 +
10 files changed, 1327 insertions(+), 1 deletion(-)
create mode 100644 src/gpf/isochrone.ts
create mode 100644 src/tools/IsochroneTool.ts
create mode 100644 test/gpf/isochrone.test.ts
create mode 100644 test/integration/level1-protocol/isochrone.test.ts
create mode 100644 test/tools/isochrone.test.ts
diff --git a/README.md b/README.md
index e2d3963..0425c2b 100644
--- a/README.md
+++ b/README.md
@@ -175,6 +175,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to
| ----------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| Géocoder un lieu | `geocode` | [Autocomplétion Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/autocompletion/) | Localiser une mairie |
| Obtenir une altitude | `altitude` | [Calcul altimétrique Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-altimetrique/) | Altitude d'un point |
+| Calculer une isochrone/isodistance | `isochrone` | [Calcul d'isochrone/isodistance Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) avec `bdtopo-valhalla` | Zone accessible à pied |
| Récupérer le contexte administratif | `adminexpress` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [ADMIN-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_ADMIN-EXPRESS) | Commune, département, région |
| Récupérer le cadastre | `cadastre` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [PARCELLAIRE-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_PARCELLAIRE-EXPRESS-PCI) | Parcelle cadastrale |
| Récupérer les documents d'urbanisme | `urbanisme` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | PLU, POS, CC |
@@ -194,7 +195,7 @@ Il n’héberge pas les données : il expose des outils MCP, interroge les servi
flowchart TB
assistant["Assistant IA / client MCP"]
geocontext["Geocontext
serveur MCP"]
- geopf["Géoplateforme IGN
géocodage · altimétrie · WFS · urbanisme · cadastre"]
+ geopf["Géoplateforme IGN
géocodage · altimétrie · isochrone · WFS · urbanisme · cadastre"]
assistant -->|"appels d'outils MCP"| geocontext
geocontext -->|"requêtes aux services IGN"| geopf
diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index e97ca45..9222b87 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -54,6 +54,7 @@ Exemple complet généré automatiquement à partir d'un appel de tool invalide
- [`gpf_wfs_get_feature_by_id`](#gpf_wfs_get_feature_by_id)
- [`gpf_wfs_get_features`](#gpf_wfs_get_features)
- [`gpf_wfs_search_types`](#gpf_wfs_search_types)
+- [`isochrone`](#isochrone)
- [`urbanisme`](#urbanisme)
## `adminexpress`
@@ -1173,6 +1174,180 @@ Title: Recherche de types WFS
+## `isochrone`
+
+Source: [src/tools/IsochroneTool.ts](../src/tools/IsochroneTool.ts)
+
+Title: Isochrone et isodistance
+
+### Description du tool
+
+- Calcule une zone isochrone ou isodistance depuis un point `lon/lat` via le service de navigation de la Géoplateforme.
+- La ressource GeoPlateforme est fixée à `bdtopo-valhalla`.
+- `cost_type="time"` calcule une zone accessible en un temps donné ; `cost_type="distance"` calcule une zone accessible dans une distance donnée.
+- `profile` accepte `car` ou `pedestrian` ; le service Valhalla de la Géoplateforme n'expose pas de profil vélo.
+- Les contraintes exposées sont celles de `bdtopo-valhalla` : exclusion (`banned`) d'un `waytype` égal à `autoroute`, `pont` ou `tunnel`.
+- `result_type="request"` renvoie une requête compacte (`get_url`) cohérente avec les tools WFS en mode request.
+- `result_type="results"` renvoie une FeatureCollection GeoJSON normalisée avec une seule feature : la géométrie calculée est placée dans `geometry` et les métadonnées du service dans `properties`.
+- Les coordonnées d'entrée sont toujours exprimées en WGS84 (`lon/lat`) ; le service est appelé avec `crs=EPSG:4326`.
+- Aucun `feature_ref` n'est renvoyé : une isochrone est une géométrie calculée à la demande, pas un objet WFS persistant.
+- (source : Géoplateforme (navigation, isochrone/isodistance)).
+
+### Input Schema
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `constraints` | array | no | Contraintes GeoPlateforme optionnelles appliquées au calcul. Elles permettent notamment d'exclure certains types de tronçons routiers. Default: []. |
+| `cost_type` | string | yes | Type de coût utilisé pour le calcul : `time` pour une isochrone, `distance` pour une isodistance. Values: time, distance. |
+| `cost_value` | number | yes | Valeur du coût utilisé pour le calcul, exprimée dans l'unité correspondante (`time_unit` ou `distance_unit`). |
+| `direction` | string | no | Sens du calcul : départ depuis le point ou arrivée vers le point. Values: departure, arrival. Default: departure. |
+| `distance_unit` | string | no | Unité utilisée lorsque `cost_type="distance"`. Values: meter, kilometer. Default: meter. |
+| `lat` | number | yes | La latitude du point. |
+| `lon` | number | yes | La longitude du point. |
+| `profile` | string | no | Mode de déplacement utilisé pour le calcul. `bdtopo-valhalla` expose `car` et `pedestrian` ; aucun profil vélo n'est disponible. Values: car, pedestrian. Default: pedestrian. |
+| `result_type` | string | no | `results` renvoie une FeatureCollection GeoJSON normalisée contenant l'isochrone calculée. `request` renvoie la requête GeoPlateforme compilée (`get_url`) pour visualisation ou débogage. Values: results, request. Default: results. |
+| `time_unit` | string | no | Unité utilisée lorsque `cost_type="time"`. Values: hour, minute, second, standard. Default: second. |
+
+
+Raw input schema
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "lon": {
+ "type": "number",
+ "minimum": -180,
+ "maximum": 180,
+ "description": "La longitude du point."
+ },
+ "lat": {
+ "type": "number",
+ "minimum": -90,
+ "maximum": 90,
+ "description": "La latitude du point."
+ },
+ "cost_type": {
+ "type": "string",
+ "enum": [
+ "time",
+ "distance"
+ ],
+ "description": "Type de coût utilisé pour le calcul : `time` pour une isochrone, `distance` pour une isodistance."
+ },
+ "cost_value": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "maximum": 50000,
+ "description": "Valeur du coût utilisé pour le calcul, exprimée dans l'unité correspondante (`time_unit` ou `distance_unit`)."
+ },
+ "profile": {
+ "type": "string",
+ "enum": [
+ "car",
+ "pedestrian"
+ ],
+ "default": "pedestrian",
+ "description": "Mode de déplacement utilisé pour le calcul. `bdtopo-valhalla` expose `car` et `pedestrian` ; aucun profil vélo n'est disponible."
+ },
+ "direction": {
+ "type": "string",
+ "enum": [
+ "departure",
+ "arrival"
+ ],
+ "default": "departure",
+ "description": "Sens du calcul : départ depuis le point ou arrivée vers le point."
+ },
+ "distance_unit": {
+ "type": "string",
+ "enum": [
+ "meter",
+ "kilometer"
+ ],
+ "default": "meter",
+ "description": "Unité utilisée lorsque `cost_type=\"distance\"`."
+ },
+ "time_unit": {
+ "type": "string",
+ "enum": [
+ "hour",
+ "minute",
+ "second",
+ "standard"
+ ],
+ "default": "second",
+ "description": "Unité utilisée lorsque `cost_type=\"time\"`."
+ },
+ "constraints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "constraint_type": {
+ "type": "string",
+ "const": "banned",
+ "default": "banned",
+ "description": "Type de contrainte GeoPlateforme Valhalla. `banned` exclut du calcul les tronçons du graphe routier qui correspondent à la condition."
+ },
+ "key": {
+ "type": "string",
+ "const": "waytype",
+ "default": "waytype",
+ "description": "Critère de contrainte GeoPlateforme Valhalla. Seul `waytype` est exposé par `bdtopo-valhalla`."
+ },
+ "operator": {
+ "type": "string",
+ "const": "=",
+ "default": "=",
+ "description": "Opérateur de contrainte GeoPlateforme Valhalla. Seul `=` est exposé par `bdtopo-valhalla`."
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "autoroute",
+ "pont",
+ "tunnel"
+ ],
+ "description": "Type de tronçon à exclure du calcul Valhalla."
+ }
+ },
+ "required": [
+ "value"
+ ],
+ "additionalProperties": false
+ },
+ "maxItems": 3,
+ "default": [],
+ "description": "Contraintes GeoPlateforme optionnelles appliquées au calcul. Elles permettent notamment d'exclure certains types de tronçons routiers."
+ },
+ "result_type": {
+ "type": "string",
+ "enum": [
+ "results",
+ "request"
+ ],
+ "default": "results",
+ "description": "`results` renvoie une FeatureCollection GeoJSON normalisée contenant l'isochrone calculée. `request` renvoie la requête GeoPlateforme compilée (`get_url`) pour visualisation ou débogage."
+ }
+ },
+ "required": [
+ "lon",
+ "lat",
+ "cost_type",
+ "cost_value"
+ ],
+ "additionalProperties": false,
+ "$schema": "http://json-schema.org/draft-07/schema#"
+}
+```
+
+
+
+### Output
+
+No single `outputSchema` is exposed. Output depends on `result_type` (`results`, `request`).
+
## `urbanisme`
Source: [src/tools/UrbanismeTool.ts](../src/tools/UrbanismeTool.ts)
diff --git a/src/gpf/isochrone.ts b/src/gpf/isochrone.ts
new file mode 100644
index 0000000..dc9e0d8
--- /dev/null
+++ b/src/gpf/isochrone.ts
@@ -0,0 +1,281 @@
+import type { Feature, FeatureCollection, Geometry } from "geojson";
+
+import { fetchJSONGet } from "../helpers/http.js";
+import logger from "../logger.js";
+import type { JsonFetcher } from "../helpers/http.js";
+
+export const ISOCHRONE_SOURCE = "Géoplateforme (navigation, isochrone/isodistance)";
+export const ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone";
+
+export const ISOCHRONE_DEFAULTS = {
+ resource: "bdtopo-valhalla",
+ profile: "pedestrian",
+ direction: "departure",
+ distance_unit: "meter",
+ time_unit: "second",
+ crs: "EPSG:4326",
+} as const;
+
+export type IsochroneCostType = "time" | "distance";
+export type IsochroneResource = typeof ISOCHRONE_DEFAULTS.resource;
+export type IsochroneProfile = "car" | "pedestrian";
+export type IsochroneDirection = "departure" | "arrival";
+export type IsochroneDistanceUnit = "meter" | "kilometer";
+export type IsochroneTimeUnit = "hour" | "minute" | "second" | "standard";
+
+export type IsochroneConstraintType = "banned";
+export type IsochroneConstraintKey = "waytype";
+export type IsochroneConstraintOperator = "=";
+export type IsochroneConstraintValue = "autoroute" | "pont" | "tunnel";
+
+export type IsochroneConstraint = {
+ constraint_type: IsochroneConstraintType;
+ key: IsochroneConstraintKey;
+ operator: IsochroneConstraintOperator;
+ value: IsochroneConstraintValue;
+};
+
+export type IsochroneRequestInput = {
+ lon: number;
+ lat: number;
+ cost_type: IsochroneCostType;
+ cost_value: number;
+ resource?: IsochroneResource;
+ profile?: IsochroneProfile;
+ direction?: IsochroneDirection;
+ distance_unit?: IsochroneDistanceUnit;
+ time_unit?: IsochroneTimeUnit;
+ constraints?: IsochroneConstraint[];
+};
+
+type NormalizedIsochroneInput = Required> & {
+ crs: string;
+ constraints: IsochroneConstraint[];
+};
+
+export type IsochroneCompiledRequest = {
+ result_type?: "request";
+ method: "GET";
+ url: string;
+ query: Record;
+ body: "";
+ get_url: string;
+};
+
+type RawIsochroneResponse = {
+ point?: string;
+ resource?: string;
+ resourceVersion?: string;
+ costType?: string;
+ costValue?: number;
+ distanceUnit?: string;
+ timeUnit?: string;
+ profile?: string;
+ direction?: string;
+ crs?: string;
+ geometry?: unknown;
+ constraints?: unknown;
+ alerts?: unknown;
+};
+
+type IsochroneFeatureProperties = {
+ source: string;
+ point: string;
+ resource: string;
+ resourceVersion?: string;
+ costType: string;
+ costValue: number;
+ distanceUnit?: string;
+ timeUnit?: string;
+ profile: string;
+ direction: string;
+ crs: string;
+ constraints?: unknown;
+ alerts?: unknown;
+};
+
+export type IsochroneFeatureCollection = FeatureCollection;
+
+/**
+ * Applies stable client-side defaults used by the MCP tool.
+ *
+ * @param input Raw isochrone request input.
+ * @returns Input completed with default navigation options.
+ */
+function normalizeIsochroneInput(input: IsochroneRequestInput): NormalizedIsochroneInput {
+ return {
+ lon: input.lon,
+ lat: input.lat,
+ cost_type: input.cost_type,
+ cost_value: input.cost_value,
+ resource: input.resource ?? ISOCHRONE_DEFAULTS.resource,
+ profile: input.profile ?? ISOCHRONE_DEFAULTS.profile,
+ direction: input.direction ?? ISOCHRONE_DEFAULTS.direction,
+ distance_unit: input.distance_unit ?? ISOCHRONE_DEFAULTS.distance_unit,
+ time_unit: input.time_unit ?? ISOCHRONE_DEFAULTS.time_unit,
+ crs: ISOCHRONE_DEFAULTS.crs,
+ constraints: input.constraints ?? [],
+ };
+}
+
+/**
+ * Converts MCP-facing constraint keys to the upstream GeoPlateforme shape.
+ *
+ * @param constraint Constraint supplied to the MCP tool.
+ * @returns Constraint encoded with upstream field names.
+ */
+function toUpstreamConstraint(constraint: IsochroneConstraint) {
+ return {
+ constraintType: constraint.constraint_type,
+ key: constraint.key,
+ operator: constraint.operator,
+ value: constraint.value,
+ };
+}
+
+/**
+ * Serializes constraints using the pipe-delimited style advertised by the upstream API.
+ *
+ * @param constraints Constraints supplied to the MCP tool.
+ * @returns Pipe-delimited JSON objects, or `undefined` when there are no constraints.
+ */
+function serializeConstraints(constraints: IsochroneConstraint[]) {
+ if (constraints.length === 0) {
+ return undefined;
+ }
+ return constraints.map((constraint) => JSON.stringify(toUpstreamConstraint(constraint))).join("|");
+}
+
+/**
+ * Builds the GeoPlateforme isochrone GET request without executing it.
+ *
+ * @param input Raw isochrone request input.
+ * @returns The compiled GET request and reusable URL.
+ */
+export function buildIsochroneRequest(input: IsochroneRequestInput): IsochroneCompiledRequest {
+ const normalizedInput = normalizeIsochroneInput(input);
+ const query: Record = {
+ resource: normalizedInput.resource,
+ point: `${normalizedInput.lon},${normalizedInput.lat}`,
+ costType: normalizedInput.cost_type,
+ costValue: String(normalizedInput.cost_value),
+ profile: normalizedInput.profile,
+ direction: normalizedInput.direction,
+ distanceUnit: normalizedInput.distance_unit,
+ timeUnit: normalizedInput.time_unit,
+ crs: normalizedInput.crs,
+ geometryFormat: "geojson",
+ };
+
+ const serializedConstraints = serializeConstraints(normalizedInput.constraints);
+ if (serializedConstraints) {
+ query.constraints = serializedConstraints;
+ }
+
+ const getUrl = `${ISOCHRONE_URL}?${new URLSearchParams(query).toString()}`;
+
+ return {
+ method: "GET",
+ url: ISOCHRONE_URL,
+ query,
+ body: "",
+ get_url: getUrl,
+ };
+}
+
+/**
+ * Maps a compiled request to the compact MCP `result_type="request"` payload.
+ *
+ * @param request Compiled isochrone request.
+ * @returns A compact request payload consistent with WFS request-mode tools.
+ */
+export function toIsochroneRequestPayload(request: IsochroneCompiledRequest): Required {
+ return {
+ result_type: "request",
+ method: request.method,
+ url: request.url,
+ query: request.query,
+ body: request.body,
+ get_url: request.get_url,
+ };
+}
+
+/**
+ * Checks whether a value looks like a GeoJSON geometry returned by the upstream service.
+ *
+ * @param value Unknown upstream geometry.
+ * @returns `true` when the value has the minimal GeoJSON geometry shape.
+ */
+function isGeoJsonGeometry(value: unknown): value is Geometry {
+ if (typeof value !== "object" || value === null) {
+ return false;
+ }
+ const record = value as Record;
+ return typeof record.type === "string" && Array.isArray(record.coordinates);
+}
+
+/**
+ * Normalizes the upstream isochrone envelope into a single-feature GeoJSON FeatureCollection.
+ *
+ * @param raw Upstream GeoPlateforme response.
+ * @param input Original request input, used as a fallback for metadata fields.
+ * @returns A GeoJSON FeatureCollection containing the computed isochrone geometry.
+ */
+export function normalizeIsochroneResponse(
+ raw: RawIsochroneResponse,
+ input: IsochroneRequestInput,
+): IsochroneFeatureCollection {
+ const normalizedInput = normalizeIsochroneInput(input);
+
+ if (raw.geometry === undefined || raw.geometry === null) {
+ throw new Error("Le service d'isochrone n'a renvoyé aucune géométrie.");
+ }
+ if (!isGeoJsonGeometry(raw.geometry)) {
+ throw new Error("La géométrie renvoyée par le service d'isochrone est invalide.");
+ }
+
+ const properties: IsochroneFeatureProperties = {
+ source: ISOCHRONE_SOURCE,
+ point: raw.point ?? `${normalizedInput.lon},${normalizedInput.lat}`,
+ resource: raw.resource ?? normalizedInput.resource,
+ ...(raw.resourceVersion ? { resourceVersion: raw.resourceVersion } : {}),
+ costType: raw.costType ?? normalizedInput.cost_type,
+ costValue: raw.costValue ?? normalizedInput.cost_value,
+ ...(raw.distanceUnit ? { distanceUnit: raw.distanceUnit } : { distanceUnit: normalizedInput.distance_unit }),
+ ...(raw.timeUnit ? { timeUnit: raw.timeUnit } : { timeUnit: normalizedInput.time_unit }),
+ profile: raw.profile ?? normalizedInput.profile,
+ direction: raw.direction ?? normalizedInput.direction,
+ crs: raw.crs ?? normalizedInput.crs,
+ ...(raw.constraints !== undefined ? { constraints: raw.constraints } : {}),
+ ...(raw.alerts !== undefined ? { alerts: raw.alerts } : {}),
+ };
+
+ const feature: Feature = {
+ type: "Feature",
+ properties,
+ geometry: raw.geometry,
+ };
+
+ return {
+ type: "FeatureCollection",
+ features: [feature],
+ };
+}
+
+/**
+ * Computes an isochrone or isodistance from the GeoPlateforme navigation service.
+ *
+ * @param input Raw isochrone request input.
+ * @param fetcher Optional JSON fetcher for tests.
+ * @returns A normalized single-feature GeoJSON FeatureCollection.
+ */
+export async function getIsochrone(
+ input: IsochroneRequestInput,
+ fetcher: JsonFetcher = fetchJSONGet,
+): Promise {
+ logger.debug(`[gpf:isochrone] getIsochrone(${JSON.stringify(input)})...`);
+
+ const request = buildIsochroneRequest(input);
+ const json = await fetcher(request.get_url);
+ return normalizeIsochroneResponse(json, input);
+}
diff --git a/src/tools/IsochroneTool.ts b/src/tools/IsochroneTool.ts
new file mode 100644
index 0000000..2460762
--- /dev/null
+++ b/src/tools/IsochroneTool.ts
@@ -0,0 +1,178 @@
+/**
+ * MCP tool exposing GeoPlateforme isochrone/isodistance computation.
+ */
+
+import BaseTool from "./BaseTool.js";
+import { z } from "zod";
+
+import {
+ buildIsochroneRequest,
+ getIsochrone,
+ ISOCHRONE_DEFAULTS,
+ ISOCHRONE_SOURCE,
+ toIsochroneRequestPayload,
+} from "../gpf/isochrone.js";
+import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
+import { lonSchema, latSchema } from "../helpers/schemas.js";
+import { generatePublishedInputSchema } from "../helpers/jsonSchema.js";
+import logger from "../logger.js";
+
+// --- Schema ---
+
+const isochroneConstraintSchema = z.object({
+ constraint_type: z
+ .literal("banned")
+ .default("banned")
+ .describe("Type de contrainte GeoPlateforme Valhalla. `banned` exclut du calcul les tronçons du graphe routier qui correspondent à la condition."),
+ key: z
+ .literal("waytype")
+ .default("waytype")
+ .describe("Critère de contrainte GeoPlateforme Valhalla. Seul `waytype` est exposé par `bdtopo-valhalla`."),
+ operator: z
+ .literal("=")
+ .default("=")
+ .describe("Opérateur de contrainte GeoPlateforme Valhalla. Seul `=` est exposé par `bdtopo-valhalla`."),
+ value: z
+ .enum(["autoroute", "pont", "tunnel"])
+ .describe("Type de tronçon à exclure du calcul Valhalla."),
+}).strict();
+
+const isochroneInputSchema = z.object({
+ lon: lonSchema,
+ lat: latSchema,
+ cost_type: z
+ .enum(["time", "distance"])
+ .describe("Type de coût utilisé pour le calcul : `time` pour une isochrone, `distance` pour une isodistance."),
+ cost_value: z
+ .number()
+ .finite()
+ .positive()
+ .max(50000)
+ .describe("Valeur du coût utilisé pour le calcul, exprimée dans l'unité correspondante (`time_unit` ou `distance_unit`)."),
+ profile: z
+ .enum(["car", "pedestrian"])
+ .default(ISOCHRONE_DEFAULTS.profile)
+ .describe("Mode de déplacement utilisé pour le calcul. `bdtopo-valhalla` expose `car` et `pedestrian` ; aucun profil vélo n'est disponible."),
+ direction: z
+ .enum(["departure", "arrival"])
+ .default(ISOCHRONE_DEFAULTS.direction)
+ .describe("Sens du calcul : départ depuis le point ou arrivée vers le point."),
+ distance_unit: z
+ .enum(["meter", "kilometer"])
+ .default(ISOCHRONE_DEFAULTS.distance_unit)
+ .describe("Unité utilisée lorsque `cost_type=\"distance\"`."),
+ time_unit: z
+ .enum(["hour", "minute", "second", "standard"])
+ .default(ISOCHRONE_DEFAULTS.time_unit)
+ .describe("Unité utilisée lorsque `cost_type=\"time\"`."),
+ constraints: z
+ .array(isochroneConstraintSchema)
+ .max(3)
+ .default([])
+ .describe("Contraintes GeoPlateforme optionnelles appliquées au calcul. Elles permettent notamment d'exclure certains types de tronçons routiers."),
+ result_type: z
+ .enum(["results", "request"])
+ .default("results")
+ .describe("`results` renvoie une FeatureCollection GeoJSON normalisée contenant l'isochrone calculée. `request` renvoie la requête GeoPlateforme compilée (`get_url`) pour visualisation ou débogage."),
+}).strict();
+
+// --- Types ---
+
+type IsochroneInput = z.infer;
+
+const isochroneRequestOutputSchema = z.object({
+ result_type: z.literal("request"),
+ method: z.literal("GET"),
+ url: z.string(),
+ query: z.record(z.string()),
+ body: z.literal(""),
+ get_url: z.string(),
+});
+
+// --- Tool ---
+
+class IsochroneTool extends BaseTool {
+ name = "isochrone";
+ title = "Isochrone et isodistance";
+ annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
+ description = [
+ "Calcule une zone isochrone ou isodistance depuis un point `lon/lat` via le service de navigation de la Géoplateforme.",
+ "La ressource GeoPlateforme est fixée à `bdtopo-valhalla`.",
+ "`cost_type=\"time\"` calcule une zone accessible en un temps donné ; `cost_type=\"distance\"` calcule une zone accessible dans une distance donnée.",
+ "`profile` accepte `car` ou `pedestrian` ; le service Valhalla de la Géoplateforme n'expose pas de profil vélo.",
+ "Les contraintes exposées sont celles de `bdtopo-valhalla` : exclusion (`banned`) d'un `waytype` égal à `autoroute`, `pont` ou `tunnel`.",
+ "`result_type=\"request\"` renvoie une requête compacte (`get_url`) cohérente avec les tools WFS en mode request.",
+ "`result_type=\"results\"` renvoie une FeatureCollection GeoJSON normalisée avec une seule feature : la géométrie calculée est placée dans `geometry` et les métadonnées du service dans `properties`.",
+ "Les coordonnées d'entrée sont toujours exprimées en WGS84 (`lon/lat`) ; le service est appelé avec `crs=EPSG:4326`.",
+ "Aucun `feature_ref` n'est renvoyé : une isochrone est une géométrie calculée à la demande, pas un objet WFS persistant.",
+ `(source : ${ISOCHRONE_SOURCE}).`
+ ].join("\n");
+
+ schema = isochroneInputSchema;
+
+ /**
+ * Exposes an MCP-compatible input schema where defaulted fields remain optional.
+ *
+ * @returns The published input schema exposed through the MCP tool definition.
+ */
+ get inputSchema() {
+ return generatePublishedInputSchema(isochroneInputSchema);
+ }
+
+ /**
+ * Formats request previews and normalized GeoJSON results into structured MCP content.
+ *
+ * @param data Raw execution result returned by the tool implementation.
+ * @returns MCP success response.
+ */
+ protected createSuccessResponse(data: unknown) {
+ if (
+ typeof data === "object" &&
+ data !== null &&
+ "result_type" in data &&
+ data.result_type === "request"
+ ) {
+ const payload = isochroneRequestOutputSchema.parse(data);
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(payload) }],
+ structuredContent: payload,
+ };
+ }
+
+ if (
+ typeof data === "object" &&
+ data !== null &&
+ "type" in data &&
+ data.type === "FeatureCollection"
+ ) {
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(data) }],
+ structuredContent: data as Record,
+ };
+ }
+
+ throw new Error(
+ "Réponse interne inattendue pour isochrone : le résultat devrait être une requête compilée ou une FeatureCollection.",
+ );
+ }
+
+ /**
+ * Computes an isochrone/isodistance or returns the compact request payload.
+ *
+ * @param input Normalized tool input.
+ * @returns Either a compiled request or a normalized GeoJSON FeatureCollection.
+ */
+ async execute(input: IsochroneInput) {
+ logger.info(`[tool] execute ${this.name} ...`, {
+ input: input
+ });
+
+ if (input.result_type === "request") {
+ return toIsochroneRequestPayload(buildIsochroneRequest(input));
+ }
+
+ return getIsochrone(input);
+ }
+}
+
+export default IsochroneTool;
diff --git a/test/gpf/isochrone.test.ts b/test/gpf/isochrone.test.ts
new file mode 100644
index 0000000..e359066
--- /dev/null
+++ b/test/gpf/isochrone.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildIsochroneRequest,
+ getIsochrone,
+ ISOCHRONE_URL,
+ normalizeIsochroneResponse,
+ toIsochroneRequestPayload,
+} from "../../src/gpf/isochrone.js";
+import { paris } from "../samples";
+
+const rawIsochroneResponse = {
+ point: "2.333333,48.866667",
+ resource: "bdtopo-valhalla",
+ resourceVersion: "2026-05-18",
+ costType: "distance",
+ costValue: 500,
+ distanceUnit: "meter",
+ profile: "pedestrian",
+ direction: "departure",
+ crs: "EPSG:4326",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [2.33, 48.86],
+ [2.34, 48.86],
+ [2.34, 48.87],
+ [2.33, 48.87],
+ [2.33, 48.86],
+ ],
+ ],
+ },
+ constraints: [],
+};
+
+function parseQuery(url: string) {
+ const parsedUrl = new URL(url);
+ return Object.fromEntries(parsedUrl.searchParams.entries());
+}
+
+describe("Test GeoPlateforme isochrone service wrapper", () => {
+ it("should build a request with stable defaults", () => {
+ const c = paris.coordinates;
+ const request = buildIsochroneRequest({
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ });
+
+ expect(request).toMatchObject({
+ method: "GET",
+ url: ISOCHRONE_URL,
+ body: "",
+ });
+ expect(request.query).toMatchObject({
+ resource: "bdtopo-valhalla",
+ point: "2.333333,48.866667",
+ costType: "distance",
+ costValue: "500",
+ profile: "pedestrian",
+ direction: "departure",
+ distanceUnit: "meter",
+ timeUnit: "second",
+ crs: "EPSG:4326",
+ geometryFormat: "geojson",
+ });
+ expect(parseQuery(request.get_url)).toMatchObject(request.query);
+ });
+
+ it("should expose a compact request payload", () => {
+ const c = paris.coordinates;
+ const payload = toIsochroneRequestPayload(buildIsochroneRequest({
+ lon: c[0],
+ lat: c[1],
+ cost_type: "time",
+ cost_value: 900,
+ }));
+
+ expect(payload).toMatchObject({
+ result_type: "request",
+ method: "GET",
+ url: ISOCHRONE_URL,
+ body: "",
+ });
+ expect(payload.get_url).toContain("costType=time");
+ });
+
+ it("should serialize optional constraints for the upstream API", () => {
+ const c = paris.coordinates;
+ const request = buildIsochroneRequest({
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ constraints: [
+ {
+ constraint_type: "banned",
+ key: "waytype",
+ operator: "=",
+ value: "autoroute",
+ },
+ ],
+ });
+
+ expect(request.query.constraints).toEqual(JSON.stringify({
+ constraintType: "banned",
+ key: "waytype",
+ operator: "=",
+ value: "autoroute",
+ }));
+ expect(parseQuery(request.get_url).constraints).toEqual(request.query.constraints);
+ });
+
+ it("should normalize the upstream response into a single-feature FeatureCollection", async () => {
+ const c = paris.coordinates;
+ const requestedUrls: string[] = [];
+ const result = await getIsochrone({
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ }, async (url) => {
+ requestedUrls.push(url);
+ return rawIsochroneResponse;
+ });
+
+ expect(requestedUrls).toHaveLength(1);
+ expect(parseQuery(requestedUrls[0])).toMatchObject({
+ resource: "bdtopo-valhalla",
+ geometryFormat: "geojson",
+ });
+ expect(result).toMatchObject({
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: rawIsochroneResponse.geometry,
+ properties: {
+ point: "2.333333,48.866667",
+ resource: "bdtopo-valhalla",
+ resourceVersion: "2026-05-18",
+ costType: "distance",
+ costValue: 500,
+ distanceUnit: "meter",
+ timeUnit: "second",
+ profile: "pedestrian",
+ direction: "departure",
+ crs: "EPSG:4326",
+ },
+ },
+ ],
+ });
+ });
+
+ it("should fill response metadata from input when upstream omits optional fields", () => {
+ const c = paris.coordinates;
+ const result = normalizeIsochroneResponse({
+ geometry: rawIsochroneResponse.geometry,
+ }, {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "time",
+ cost_value: 900,
+ });
+
+ expect(result.features[0].properties).toMatchObject({
+ point: "2.333333,48.866667",
+ resource: "bdtopo-valhalla",
+ costType: "time",
+ costValue: 900,
+ distanceUnit: "meter",
+ timeUnit: "second",
+ profile: "pedestrian",
+ direction: "departure",
+ crs: "EPSG:4326",
+ });
+ });
+
+ it("should throw when upstream returns no geometry", () => {
+ const c = paris.coordinates;
+ expect(() => normalizeIsochroneResponse({}, {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ })).toThrow("Le service d'isochrone n'a renvoyé aucune géométrie.");
+ });
+
+ it("should throw when upstream geometry is not GeoJSON", () => {
+ const c = paris.coordinates;
+ expect(() => normalizeIsochroneResponse({
+ geometry: "POLYGON EMPTY",
+ }, {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ })).toThrow("La géométrie renvoyée par le service d'isochrone est invalide.");
+ });
+});
diff --git a/test/integration/level1-protocol/isochrone.test.ts b/test/integration/level1-protocol/isochrone.test.ts
new file mode 100644
index 0000000..949c940
--- /dev/null
+++ b/test/integration/level1-protocol/isochrone.test.ts
@@ -0,0 +1,98 @@
+/**
+ * Integration test: isochrone tool with real API calls.
+ */
+
+import { describe, it, expect } from "vitest";
+import { callTool } from "../helpers/mcp-client.js";
+import { withMcpServer } from "../helpers/level1-fixtures.js";
+import { expectToolCallToThrow } from "../helpers/level1-assertions.js";
+import { INTEGRATION_CONFIG } from "../config/shared.js";
+import { paris } from "../samples.js";
+
+interface IsochroneFeatureCollection {
+ type: "FeatureCollection";
+ features: Array<{
+ type: "Feature";
+ properties: {
+ resource: string;
+ costType: string;
+ costValue: number;
+ profile: string;
+ direction: string;
+ crs: string;
+ };
+ geometry: {
+ type: string;
+ coordinates: unknown[];
+ };
+ }>;
+}
+
+interface IsochroneRequestPayload {
+ result_type: "request";
+ method: "GET";
+ url: string;
+ query: Record;
+ body: "";
+ get_url: string;
+}
+
+describe("Isochrone Tool (integration)", () => {
+ const { getHandle } = withMcpServer();
+
+ it("should return a normalized GeoJSON FeatureCollection for a small Paris isodistance", async () => {
+ const result = await callTool(getHandle().client, "isochrone", {
+ lon: paris.lon,
+ lat: paris.lat,
+ cost_type: "distance",
+ cost_value: 500,
+ });
+
+ expect(result.type).toBe("FeatureCollection");
+ expect(result.features).toHaveLength(1);
+ expect(result.features[0].type).toBe("Feature");
+ expect(result.features[0].geometry.type).toMatch(/Polygon/);
+ expect(Array.isArray(result.features[0].geometry.coordinates)).toBe(true);
+ expect(result.features[0].properties).toMatchObject({
+ resource: "bdtopo-valhalla",
+ costType: "distance",
+ costValue: 500,
+ profile: "pedestrian",
+ direction: "departure",
+ crs: "EPSG:4326",
+ });
+ }, INTEGRATION_CONFIG.timeout);
+
+ it("should return a compact request payload without calling the upstream service", async () => {
+ const result = await callTool(getHandle().client, "isochrone", {
+ lon: paris.lon,
+ lat: paris.lat,
+ cost_type: "time",
+ cost_value: 900,
+ result_type: "request",
+ });
+
+ expect(result).toMatchObject({
+ result_type: "request",
+ method: "GET",
+ url: "https://data.geopf.fr/navigation/isochrone",
+ body: "",
+ });
+ expect(result.query).toMatchObject({
+ resource: "bdtopo-valhalla",
+ costType: "time",
+ costValue: "900",
+ geometryFormat: "geojson",
+ });
+ expect(result.get_url).toContain("costType=time");
+ }, INTEGRATION_CONFIG.timeout);
+
+ it("should return an error for invalid cost type", async () => {
+ await expectToolCallToThrow(callTool(getHandle().client, "isochrone", {
+ lon: paris.lon,
+ lat: paris.lat,
+ cost_type: "duration",
+ cost_value: 500,
+ }));
+ }, INTEGRATION_CONFIG.timeout);
+});
diff --git a/test/integration/level2-agent/level2-agent.test.ts b/test/integration/level2-agent/level2-agent.test.ts
index 3a4f296..003368c 100644
--- a/test/integration/level2-agent/level2-agent.test.ts
+++ b/test/integration/level2-agent/level2-agent.test.ts
@@ -65,6 +65,20 @@ const mcpScenarios = [
expect(containsNumberInRange(normalizedFinalMessage, 1000, 1100)).toBe(true);
},
},
+ {
+ testName: "should call isochrone directly for a pedestrian isodistance from coordinates",
+ userInput: "Calcule l'isodistance à pied de 500 mètres autour du point lon=2.333333 lat=48.866667. Réponds brièvement avec le mot isodistance.",
+ expectedResponseFragments: ["isodistance"],
+ toolMode: "mcp",
+ requiredToolCalls: ["isochrone"],
+ },
+ {
+ testName: "should chain geocode and isochrone tools for a place-based walking isochrone",
+ userInput: "Calcule l'isochrone à pied à 15 minutes depuis la mairie de Vincennes. Réponds brièvement avec le mot isochrone.",
+ expectedResponseFragments: ["isochrone"],
+ toolMode: "mcp",
+ requiredToolCalls: ["geocode", "isochrone"],
+ },
{
testName: "should answer the question about 14 lycées near the chateau de Vincennes",
userInput: "Combien de lycées sont situés à 2km du chateau de vincennes?",
diff --git a/test/integration/samples.ts b/test/integration/samples.ts
index 5fda541..ae3db2e 100644
--- a/test/integration/samples.ts
+++ b/test/integration/samples.ts
@@ -17,6 +17,7 @@ export const EXPECTED_TOOL_NAMES = [
"altitude",
"adminexpress",
"cadastre",
+ "isochrone",
"urbanisme",
"assiette_sup",
"gpf_wfs_search_types",
diff --git a/test/tools/isochrone.test.ts b/test/tools/isochrone.test.ts
new file mode 100644
index 0000000..91b158f
--- /dev/null
+++ b/test/tools/isochrone.test.ts
@@ -0,0 +1,365 @@
+import { describe, it, expect } from "vitest";
+
+import { ISOCHRONE_SOURCE, type IsochroneFeatureCollection } from "../../src/gpf/isochrone";
+import IsochroneTool from "../../src/tools/IsochroneTool";
+import { paris } from "../samples";
+
+const isochroneFeatureCollection: IsochroneFeatureCollection = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ properties: {
+ source: ISOCHRONE_SOURCE,
+ point: "2.333333,48.866667",
+ resource: "bdtopo-valhalla",
+ resourceVersion: "2026-05-18",
+ costType: "distance",
+ costValue: 500,
+ distanceUnit: "meter",
+ timeUnit: "second",
+ profile: "pedestrian",
+ direction: "departure",
+ crs: "EPSG:4326",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [2.33, 48.86],
+ [2.34, 48.86],
+ [2.34, 48.87],
+ [2.33, 48.87],
+ [2.33, 48.86],
+ ],
+ ],
+ },
+ },
+ ],
+};
+
+describe("Test IsochroneTool", () => {
+ class TestableIsochroneTool extends IsochroneTool {
+ async execute(): Promise {
+ return isochroneFeatureCollection;
+ }
+ }
+
+ it("should expose an enriched MCP definition", () => {
+ const tool = new IsochroneTool();
+ expect(tool.toolDefinition.title).toEqual("Isochrone et isodistance");
+ expect(tool.toolDefinition.inputSchema.properties?.lon).toMatchObject({
+ type: "number",
+ minimum: -180,
+ maximum: 180,
+ });
+ expect(tool.toolDefinition.inputSchema.properties?.lat).toMatchObject({
+ type: "number",
+ minimum: -90,
+ maximum: 90,
+ });
+ expect(tool.toolDefinition.inputSchema.properties?.cost_type).toMatchObject({
+ type: "string",
+ enum: ["time", "distance"],
+ });
+ expect(tool.toolDefinition.inputSchema.properties?.result_type).toMatchObject({
+ type: "string",
+ enum: ["results", "request"],
+ default: "results",
+ });
+ expect(tool.toolDefinition.inputSchema.properties?.profile).toMatchObject({
+ type: "string",
+ enum: ["car", "pedestrian"],
+ default: "pedestrian",
+ });
+ expect(tool.toolDefinition.inputSchema.properties?.constraints).toMatchObject({
+ type: "array",
+ maxItems: 3,
+ });
+ expect(tool.toolDefinition.inputSchema.properties?.resource).toBeUndefined();
+ expect(tool.toolDefinition.inputSchema.properties?.crs).toBeUndefined();
+ expect(tool.toolDefinition.outputSchema).toBeUndefined();
+ });
+
+ it("should return text content and structuredContent for normalized GeoJSON results", async () => {
+ const c = paris.coordinates;
+ const tool = new TestableIsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ },
+ },
+ });
+
+ expect(response.isError).toBeUndefined();
+ expect(response.content[0]).toMatchObject({
+ type: "text",
+ });
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
+ }
+ const payload = JSON.parse(textContent.text);
+ expect(payload).toMatchObject({
+ type: "FeatureCollection",
+ features: [
+ expect.objectContaining({
+ type: "Feature",
+ geometry: expect.objectContaining({
+ type: "Polygon",
+ }),
+ }),
+ ],
+ });
+ expect(response.structuredContent).toMatchObject(payload);
+ });
+
+ it("should return text content and structuredContent for request mode", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "time",
+ cost_value: 900,
+ result_type: "request",
+ },
+ },
+ });
+
+ expect(response.isError).toBeUndefined();
+ expect(response.content[0]).toMatchObject({
+ type: "text",
+ });
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
+ }
+ const payload = JSON.parse(textContent.text);
+ expect(payload).toMatchObject({
+ result_type: "request",
+ method: "GET",
+ url: "https://data.geopf.fr/navigation/isochrone",
+ body: "",
+ });
+ expect(payload.query).toMatchObject({
+ resource: "bdtopo-valhalla",
+ costType: "time",
+ costValue: "900",
+ geometryFormat: "geojson",
+ });
+ expect(payload.get_url).toContain("costType=time");
+ expect(response.structuredContent).toMatchObject(payload);
+ });
+
+ it("should default valhalla constraint fields in request mode", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ result_type: "request",
+ constraints: [{ value: "tunnel" }],
+ },
+ },
+ });
+
+ 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.query.constraints).toEqual(JSON.stringify({
+ constraintType: "banned",
+ key: "waytype",
+ operator: "=",
+ value: "tunnel",
+ }));
+ });
+
+ it("should reject out-of-range coordinates at the tool boundary", async () => {
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: 600,
+ lat: 600,
+ cost_type: "distance",
+ cost_value: 500,
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.content[0]).toMatchObject({
+ type: "text",
+ });
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ name: "lon",
+ code: "too_big",
+ }),
+ ]),
+ });
+ });
+
+ it("should reject invalid enum values", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "duration",
+ cost_value: 500,
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ name: "cost_type",
+ code: "invalid_enum_value",
+ }),
+ ]),
+ });
+ });
+
+ it("should reject unsupported bike profile", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ profile: "bike",
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ name: "profile",
+ code: "invalid_enum_value",
+ }),
+ ]),
+ });
+ });
+
+ it("should reject non-valhalla constraint values", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ constraints: [
+ {
+ value: "route_empierree",
+ },
+ ],
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ code: "invalid_enum_value",
+ detail: expect.stringContaining("autoroute"),
+ }),
+ ]),
+ });
+ });
+
+ it("should reject crs as a public input", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ crs: "EPSG:2154",
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ name: "crs",
+ code: "unknown_parameter",
+ }),
+ ]),
+ });
+ });
+
+ it("should reject resource as a public input", async () => {
+ const c = paris.coordinates;
+ const tool = new IsochroneTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "isochrone",
+ arguments: {
+ lon: c[0],
+ lat: c[1],
+ cost_type: "distance",
+ cost_value: 500,
+ resource: "bdtopo-pgr",
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ name: "resource",
+ code: "unknown_parameter",
+ }),
+ ]),
+ });
+ });
+});
diff --git a/test/tools/strict-input.test.ts b/test/tools/strict-input.test.ts
index c541a3a..892e3de 100644
--- a/test/tools/strict-input.test.ts
+++ b/test/tools/strict-input.test.ts
@@ -9,6 +9,7 @@ import GpfWfsDescribeTypeTool from "../../src/tools/GpfWfsDescribeTypeTool";
import GpfWfsGetFeatureByIdTool from "../../src/tools/GpfWfsGetFeatureByIdTool";
import GpfWfsGetFeaturesTool from "../../src/tools/GpfWfsGetFeaturesTool";
import GpfWfsSearchTypesTool from "../../src/tools/GpfWfsSearchTypesTool";
+import IsochroneTool from "../../src/tools/IsochroneTool";
import UrbanismeTool from "../../src/tools/UrbanismeTool";
const strictInputCases = [
@@ -60,6 +61,16 @@ const strictInputCases = [
tool: new GpfWfsSearchTypesTool(),
validArguments: { query: "batiment" },
},
+ {
+ label: "IsochroneTool",
+ tool: new IsochroneTool(),
+ validArguments: {
+ lon: 2.3522,
+ lat: 48.8566,
+ cost_type: "distance",
+ cost_value: 500,
+ },
+ },
{
label: "UrbanismeTool",
tool: new UrbanismeTool(),