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(),