From 3fd376486f5b57e6f4e3b92acea22c7e4b4f822b Mon Sep 17 00:00:00 2001
From: esgn <5435148+esgn@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:12:48 +0200
Subject: [PATCH 1/4] fix(spatial_extras): null when not computable, 0 only for
an empty overlap
- intersection_area: null when the feature or the filter is not areal, or when
the filter geometry could not be prepared; 0 only when both are areal and
do not overlap
- every extra is null on a missing or empty geometry
- length sums the linear parts of a GeometryCollection; area of an empty
Polygon is null
- measures sum the parts of a GeometryCollection, overlaps included (JTS)
- state the contract in deriveFromGeometry and in the LLM-facing descriptions
- regenerate docs/mcp-tools.md
---
docs/mcp-tools.md | 8 +-
src/wfs/schema.ts | 8 +-
src/wfs/spatialExtras.ts | 89 +++++++++++++----
test/tools/wfs/getFeatureById.test.ts | 6 +-
test/wfs/response.test.ts | 134 ++++++++++++++++++++++++++
5 files changed, 217 insertions(+), 28 deletions(-)
diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index 1955974d..5ab202de 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -1117,7 +1117,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
| `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. |
| `order_by` | array | non | Liste ordonnée des critères de tri. |
| `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. |
-| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).
`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire d'intersection (en m²) entre la géométrie de l'objet renvoyé, qui doit être surfacique, et le filtre spatial.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse. Valeur par défaut : []. |
+| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. |
| `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. |
| `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. |
@@ -1391,7 +1391,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
]
},
"default": [],
- "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).\n`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire d'intersection (en m²) entre la géométrie de l'objet renvoyé, qui doit être surfacique, et le filtre spatial.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSi une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse."
+ "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu."
}
},
"required": [
@@ -2060,7 +2060,7 @@ Utiliser `spatial_extras` pour renvoyer une information géométrique dérivée
| --- | --- | --- | --- |
| `feature_id` | string | oui | Identifiant GPF exact de l'objet à récupérer, par exemple `commune.8952`. |
| `select` | array | non | Liste des propriétés non géométriques à renvoyer. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. |
-| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).
`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse. Valeur par défaut : []. |
+| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
| `typename` | string | oui | Nom exact du type GPF à interroger, par exemple `ADMINEXPRESS-COG.LATEST:commune`. |
@@ -2101,7 +2101,7 @@ Utiliser `spatial_extras` pour renvoyer une information géométrique dérivée
]
},
"default": [],
- "description": "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).\n`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSi une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse."
+ "description": "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu."
}
},
"required": [
diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts
index d6427400..0f5b8b45 100644
--- a/src/wfs/schema.ts
+++ b/src/wfs/schema.ts
@@ -151,8 +151,8 @@ export const GPF_SPATIAL_FILTER_DOCNAMES = GPF_GET_FEATURES_SPATIAL_FILTER_KEYS
const SPATIAL_EXTRAS_BASE_DESCRIPTION_LINES = [
"`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.",
"`bbox` est la boîte englobante de la géométrie.",
- "`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).",
- "`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).",
+ "`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).",
+ "`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).",
] as const;
function buildSpatialExtrasDescription(
@@ -167,7 +167,7 @@ function buildSpatialExtrasDescription(
`${SPATIAL_EXTRAS_BASE_DESCRIPTION_LINES.join("\n")}\n`+
optionalFilterLine+
"Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\n"+
- "Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse.";
+ "Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu.";
}
function assertSpatialExtraSpatialFilterConsistency(input : Record, ctx : z.RefinementCtx) {
@@ -192,7 +192,7 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({
"chaque objet",
GPF_SPATIAL_EXTRAS_DOCNAMES,
"`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n"+
- "`intersection_area` est l'aire d'intersection (en m²) entre la géométrie de l'objet renvoyé, qui doit être surfacique, et le filtre spatial."
+ "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre."
)),
});
diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts
index 76829db9..fb8f7e99 100644
--- a/src/wfs/spatialExtras.ts
+++ b/src/wfs/spatialExtras.ts
@@ -5,7 +5,7 @@ import { area } from "@turf/area";
import { intersect } from "@turf/intersect";
import { circle } from "@turf/circle";
import { bboxPolygon } from "@turf/bbox-polygon";
-import type { Geometry, MultiPolygon, Point, Polygon, Position } from "geojson";
+import type { Geometry, LineString, MultiLineString, MultiPolygon, Point, Polygon, Position } from "geojson";
import distance from "../helpers/distance.js";
import { feature, featureCollection } from "@turf/helpers";
import { getSpatialFilter } from "./spatialFilter.js";
@@ -66,6 +66,9 @@ function spatialFilterToCentroid(spatialFilter: SpatialFilter, resolvedGeometryR
/** Accepts any geometry and returns it as a Polygon or MultiPolygon, filtering
* all 0D (point) and 1D (line) sub-geometries out.
+ *
+ * The polygons of a GeometryCollection are concatenated, not unioned: measures
+ * sum their parts, overlaps included, like JTS `GeometryCollection.getArea()`.
*/
function geometryToPolygons(geom: Geometry) : Polygon | MultiPolygon | null {
switch(geom.type) {
@@ -96,6 +99,39 @@ function geometryToPolygons(geom: Geometry) : Polygon | MultiPolygon | null {
}
}
+/** Accepts any geometry and returns its linear (1D) parts as a LineString or
+ * MultiLineString, dropping empty lines, points and polygons. Returns null when
+ * nothing linear remains.
+ */
+function geometryToLines(geom: Geometry) : LineString | MultiLineString | null {
+ switch (geom.type) {
+ case "LineString":
+ return geom.coordinates.length >= 2 ? geom : null;
+ case "MultiLineString": {
+ const coordinates = geom.coordinates.filter((line) => line.length >= 2);
+ return coordinates.length == 0 ? null : { type: "MultiLineString", coordinates };
+ }
+ case "GeometryCollection": {
+ const coordinates = geom.geometries
+ .map(geometryToLines)
+ .filter((lines) => lines !== null)
+ .flatMap((lines) => lines.type == "LineString" ? [lines.coordinates] : lines.coordinates);
+ return coordinates.length == 0 ? null : { type: "MultiLineString", coordinates };
+ }
+ default:
+ return null;
+ }
+}
+
+/** True when `geometry` is a GeoJSON geometry holding at least one finite position. */
+function isComputableGeometry(geometry: unknown) : geometry is Geometry {
+ try {
+ return bbox(geometry as Geometry).every(Number.isFinite);
+ } catch {
+ return false;
+ }
+}
+
type SpatialContext = {
filterCentroid: Point | null,
filterPolygons: Polygon | MultiPolygon | null
@@ -153,34 +189,37 @@ export function dropEmptyRings(geom: Geometry) : Polygon | MultiPolygon | null {
return null;
}
-/** Return the areal (2D) intersection between a geometry and a spatial filter.
- * null if the intersection has no area.
+/** Return the area (m²) of the part of a geometry lying inside a spatial filter.
+ *
+ * null when it cannot be computed: the geometry or the filter has no areal part,
+ * or the filter geometry could not be prepared. 0 only when both are areal and
+ * do not overlap.
*/
-function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: SpatialFilter, filterPolygons: Polygon | MultiPolygon | null) : Polygon | MultiPolygon | null {
+function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: SpatialFilter, filterPolygons: Polygon | MultiPolygon | null) : number | null {
const geo = geometryToPolygons(geom);
if (!geo) {
- return null;
+ return null; // non-areal feature
}
switch (spatialFilter.operator) {
case "intersects_point":
- return null; // non-2D filter
+ return null; // non-areal filter (already rejected by the input schema)
// Note: `dwithin_point` matches a feature as soon as any part of it
// lies within `distance_m`, so the intersection is needed even for it.
case "dwithin_point":
case "intersects_feature":
case "travel_time": {
- if (!filterPolygons) return null; // non-2D filter
+ if (!filterPolygons) return null; // non-areal filter, or filter preparation failed
// Whatever lies outside the feature's bbox cannot intersect it, so clipping the
// reference first leaves the result unchanged while polyclip only ever processes
// the neighbouring vertices.
const clippedFilter = dropEmptyRings(bboxClip(filterPolygons, bbox(geo)).geometry);
- if (!clippedFilter) return null; // no areal overlap
+ if (!clippedFilter) return 0; // no overlap
const inter = intersect(featureCollection([feature(clippedFilter), feature(geo)]));
- return inter == null ? null : inter.geometry;
+ return inter == null ? 0 : area(inter.geometry);
}
case "bbox": {
- const clipped = bboxClip(geo, [spatialFilter.west, spatialFilter.south, spatialFilter.east, spatialFilter.north]);
- return dropEmptyRings(clipped.geometry);
+ const clipped = dropEmptyRings(bboxClip(geo, [spatialFilter.west, spatialFilter.south, spatialFilter.east, spatialFilter.north]).geometry);
+ return clipped == null ? 0 : area(clipped);
}
default: // Make a compile-time error if a filter is missing from the switch
const noFilter: never = spatialFilter;
@@ -188,6 +227,16 @@ function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: Spatia
}
}
+/** Compute the requested `spatial_extras` of one returned feature.
+ *
+ * Contract shared by every extra:
+ * 1. A mismatch that is knowable before the main WFS query (e.g. `area` on a layer
+ * whose geometry format is linear) is rejected upstream with an error, never here.
+ * 2. A value that cannot be computed for this feature (missing or empty geometry,
+ * no part of the required dimension, non-areal filter, internal failure) is `null`.
+ * 3. Otherwise the value is the computed one, `0` included: `0` always means the
+ * computation ran (e.g. no overlap with the filter), never "not computable".
+ */
export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPostProcessInput, context: SpatialContext) {
const spatial_extras = input.spatial_extras;
@@ -197,8 +246,14 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo
return ret;
}
- // Assume that geometry is a Geometry. Otherwise, all the required spatial_extra will be set to null.
- const geo = geometry as Geometry;
+ if (!isComputableGeometry(geometry)) {
+ // Contract case 2: nothing can be computed on a missing or empty geometry.
+ for (const extra of spatial_extras) {
+ ret[extra] = null;
+ }
+ return ret;
+ }
+ const geo = geometry;
if (spatial_extras.includes("centroid")) {
try {
@@ -223,7 +278,8 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo
if (spatial_extras.includes("length")) {
try {
- ret.length = (geo.type == "LineString" || geo.type == "MultiLineString") ? turfLength(feature(geo), { units: "meters" }) : null;
+ const geoAsLines = geometryToLines(geo);
+ ret.length = geoAsLines ? turfLength(feature(geoAsLines), { units: "meters" }) : null;
} catch {
ret.length = null;
}
@@ -232,7 +288,7 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo
if (spatial_extras.includes("area")) {
try {
const geoAsPolygons = geometryToPolygons(geo);
- ret.area = geoAsPolygons ? area(geo) : null;
+ ret.area = geoAsPolygons ? area(geoAsPolygons) : null;
} catch {
ret.area = null;
}
@@ -261,8 +317,7 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo
if (requires_intersection_area) {
try {
- const intersection = intersectionAreaWithSpatialFilter(geo, spatialFilter, context.filterPolygons);
- ret.intersection_area = intersection ? area(intersection) : 0;
+ ret.intersection_area = intersectionAreaWithSpatialFilter(geo, spatialFilter, context.filterPolygons);
} catch {
ret.intersection_area = null;
}
diff --git a/test/tools/wfs/getFeatureById.test.ts b/test/tools/wfs/getFeatureById.test.ts
index f3f6530d..c8cc7ff3 100644
--- a/test/tools/wfs/getFeatureById.test.ts
+++ b/test/tools/wfs/getFeatureById.test.ts
@@ -96,10 +96,10 @@ describe("Test GpfGetFeatureByIdTool", () => {
description: "Éléments calculés depuis la géométrie à renvoyer pour l'objet. Peut inclure `centroid`, `bbox`, `length` et `area`, aucun par défaut.\n"+
"`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n"+
"`bbox` est la boîte englobante de la géométrie.\n"+
- "`length` est renvoyé en m et ne peut être utilisé qu'avec des géométries linéaires (LineString, MultiLineString).\n"+
- "`area` est renvoyé en m² et ne peut être utilisé qu'avec des géométries surfaciques (Polygon, MultiPolygon).\n"+
+ "`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n"+
+ "`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n"+
"Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\n"+
- "Si une valeur n'est pas calculable pour une autre raison, elle sera remplacée par `null` dans la réponse.",
+ "Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu.",
},
select: {
type: "array",
diff --git a/test/wfs/response.test.ts b/test/wfs/response.test.ts
index 8d5aa6f5..f8132f42 100644
--- a/test/wfs/response.test.ts
+++ b/test/wfs/response.test.ts
@@ -8,6 +8,7 @@ import {
postProcessFeatureCollection,
} from "../../src/wfs/response";
import { type SpatialExtraOptions } from "../../src/wfs/schema";
+import type { Geometry } from "geojson";
describe("wfs_engine/response", () => {
function getFeatures(
@@ -377,6 +378,139 @@ describe("wfs_engine/response", () => {
);
expect(intersectionArea([[outsideRing]])).toEqual(0);
});
+
+ // Contract shared by every extra: `null` when the value cannot be computed for
+ // this feature, a number (`0` included) when the computation ran.
+ describe("spatial_extras contract", () => {
+ const square = [[2, 48], [2.1, 48], [2.1, 48.1], [2, 48.1], [2, 48]];
+ const bbox_filter = { west: 1.9, south: 47.9, east: 2.2, north: 48.2 };
+ const intersects_feature_filter = { typename: "REF:type", feature_id: "ref.1" };
+
+ function derive(
+ geometry: unknown,
+ spatial_extras: string[],
+ filters: Record = {},
+ resolvedGeometryRef?: Geometry,
+ ) {
+ const result = transformFeatureCollectionResponse({
+ type: "FeatureCollection",
+ features: [{ id: "f.1", geometry, properties: {} }],
+ }, {
+ typename: "TEST:type",
+ spatial_extras,
+ ...filters,
+ } as Parameters[1], resolvedGeometryRef);
+
+ return getFeatures(result)[0];
+ }
+
+ it.each([
+ ["an absent geometry", undefined],
+ ["a null geometry", null],
+ ["an empty Point", { type: "Point", coordinates: [] }],
+ ["an empty Polygon", { type: "Polygon", coordinates: [] }],
+ ["an empty GeometryCollection", { type: "GeometryCollection", geometries: [] }],
+ ])("should return null for every extra on %s", (_label, geometry) => {
+ const feature = derive(
+ geometry,
+ ["centroid", "bbox", "length", "area", "distance_to_filter", "intersection_area"],
+ { bbox_filter },
+ );
+
+ expect(feature).toMatchObject({
+ centroid: null,
+ bbox: null,
+ length: null,
+ area: null,
+ distance_to_filter: null,
+ intersection_area: null,
+ });
+ });
+
+ it("should return null for intersection_area on a non-areal feature, like area", () => {
+ const feature = derive(
+ { type: "LineString", coordinates: [[2, 48], [2.1, 48.1]] },
+ ["area", "intersection_area"],
+ { bbox_filter },
+ );
+
+ expect(feature.area).toBeNull();
+ expect(feature.intersection_area).toBeNull();
+ });
+
+ it("should return null for intersection_area when the intersects_feature reference is not areal", () => {
+ const feature = derive(
+ { type: "Polygon", coordinates: [square] },
+ ["intersection_area"],
+ { intersects_feature_filter },
+ { type: "LineString", coordinates: [[1.9, 47.9], [2.2, 48.2]] },
+ );
+
+ expect(feature.intersection_area).toBeNull();
+ });
+
+ it("should return null for filter-dependent extras when the reference geometry could not be prepared", () => {
+ const feature = derive(
+ { type: "Polygon", coordinates: [square] },
+ ["distance_to_filter", "intersection_area"],
+ { intersects_feature_filter },
+ );
+
+ expect(feature.distance_to_filter).toBeNull();
+ expect(feature.intersection_area).toBeNull();
+ });
+
+ it("should return 0 for intersection_area when both geometries are areal and do not overlap", () => {
+ const farSquare = square.map(([lon, lat]) => [lon + 5, lat]);
+ const feature = derive(
+ { type: "Polygon", coordinates: [farSquare] },
+ ["intersection_area"],
+ { intersects_feature_filter },
+ { type: "Polygon", coordinates: [square] },
+ );
+
+ expect(feature.intersection_area).toEqual(0);
+ });
+
+ it("should sum the linear parts of a GeometryCollection for length and ignore the other parts", () => {
+ const line = { type: "LineString", coordinates: [[2.3, 48.8], [2.31, 48.81]] };
+ const lineLength = derive(line, ["length"]).length as number;
+
+ const feature = derive({
+ type: "GeometryCollection",
+ geometries: [line, line, { type: "Polygon", coordinates: [square] }],
+ }, ["length"]);
+
+ expect(feature.length as number).toBeCloseTo(2 * lineLength, 6);
+ });
+
+ it("should sum the polygons of a GeometryCollection for area, overlaps included, like JTS", () => {
+ const shifted = square.map(([lon, lat]) => [lon + 0.05, lat]); // overlaps half of `square`
+ const squareArea = derive({ type: "Polygon", coordinates: [square] }, ["area"]).area as number;
+
+ const feature = derive({
+ type: "GeometryCollection",
+ geometries: [
+ { type: "Polygon", coordinates: [square] },
+ { type: "Polygon", coordinates: [shifted] },
+ ],
+ }, ["area"]);
+
+ expect(feature.area as number).toBeCloseTo(2 * squareArea, 0);
+ });
+
+ it("should return null for length when a GeometryCollection has no usable linear part", () => {
+ const feature = derive({
+ type: "GeometryCollection",
+ geometries: [
+ { type: "Polygon", coordinates: [square] },
+ { type: "LineString", coordinates: [] },
+ ],
+ }, ["length"]);
+
+ expect(feature.length).toBeNull();
+ });
+ });
});
// --- postProcessFeatureCollection ---
From 9e67f18cb89d3c3b6ab7e94ff24279cf27988974 Mon Sep 17 00:00:00 2001
From: esgn <5435148+esgn@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:31:28 +0200
Subject: [PATCH 2/4] fix(spatial_extras): validate filter-dependent extras in
the input schema
- move "requires a spatial filter" from compileQueryParts to the zod
refinement: invalid-tool-params instead of execution-error, raised before
any catalog or network call
- reject distance_to_filter with intersects_point_filter (always 0)
- list only the compatible filters in the error message
- state the rule in the spatial_extras description, regenerate docs/mcp-tools.md
- move the related tests from compileQueryParts to the tool
---
docs/mcp-tools.md | 4 +-
src/wfs/queryPreparation.ts | 7 ---
src/wfs/schema.ts | 54 ++++++++++++++++++++----
src/wfs/spatialExtras.ts | 6 ++-
test/tools/wfs/getFeatures.test.ts | 68 ++++++++++++++++++++++++++++++
test/wfs/queryPreparation.test.ts | 12 +-----
6 files changed, 120 insertions(+), 31 deletions(-)
diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index 5ab202de..76a06dbd 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -1117,7 +1117,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
| `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. |
| `order_by` | array | non | Liste ordonnée des critères de tri. |
| `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. |
-| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
+| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. |
| `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. |
| `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. |
@@ -1391,7 +1391,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
]
},
"default": [],
- "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu."
+ "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu."
}
},
"required": [
diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts
index 575ce398..d3cc9a45 100644
--- a/src/wfs/queryPreparation.ts
+++ b/src/wfs/queryPreparation.ts
@@ -25,7 +25,6 @@ import type {
import {
GPF_SPATIAL_FILTER_DOCNAMES,
queryIsGetFeaturesInput,
- spatialExtraRequiresFilter,
} from "./schema.js"
import {
@@ -188,7 +187,6 @@ export function compileQueryParts(
let geometryName: string | undefined;
const isGetFeatures = queryIsGetFeaturesInput(input);
const spatialFilter = getSpatialFilter(input);
- const spatialExtras = isGetFeatures ? input.spatial_extras : [];
const fragments: string[] = [];
// Keep the spatial predicate first: the GeoPlateforme GeoServer is sensitive
@@ -221,11 +219,6 @@ export function compileQueryParts(
const noFilter: never = spatialFilter;
throw new Error(`Unhandled filter case: ${noFilter}`);
}
- } else if (spatialExtras.length > 0) {
- const faultyExtra = spatialExtras.filter(spatialExtraRequiresFilter);
- if (faultyExtra.length > 0) {
- throw new Error(`Impossible de demander ${faultyExtra} sans spécifier de filtre géométrique (à choisir parmi ${GPF_SPATIAL_FILTER_DOCNAMES}).`);
- }
}
for (const clause of input.where ?? []) {
diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts
index 0f5b8b45..7465d711 100644
--- a/src/wfs/schema.ts
+++ b/src/wfs/schema.ts
@@ -170,16 +170,51 @@ function buildSpatialExtrasDescription(
"Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu.";
}
-function assertSpatialExtraSpatialFilterConsistency(input : Record, ctx : z.RefinementCtx) {
+/** For each filter-dependent extra, the spatial filter that makes it meaningless, and why. */
+const FILTER_DEPENDENT_EXTRA_RULES: Record = {
+ distance_to_filter: {
+ incompatibleFilter: "intersects_point_filter",
+ incompatibleReason: "vaut toujours 0 avec `intersects_point_filter`, puisque chaque objet renvoyé contient le point : pour classer des objets selon leur distance à un point, utilisez plutôt `dwithin_point_filter`",
+ },
+ intersection_area: {
+ incompatibleFilter: "intersects_point_filter",
+ incompatibleReason: "ne peut pas être calculé avec `intersects_point_filter`, car un point n'a pas de surface : utilisez plutôt un filtre surfacique",
+ },
+};
+
+/**
+ * Rejects the filter-dependent extras (`distance_to_filter`, `intersection_area`)
+ * when no spatial filter is given, or when the given filter makes them meaningless.
+ * Both only depend on the input, hence invalid tool parameters rather than
+ * execution errors.
+ */
+function assertSpatialExtraSpatialFilterConsistency(input: z.infer, ctx: z.RefinementCtx) {
const usedSpatialFilters = GPF_GET_FEATURES_SPATIAL_FILTER_KEYS.filter((key) => input[key] !== undefined);
- const usedSpatiaExtras = input.spatial_extras as SpatialExtraOptions[];
- if (usedSpatialFilters.includes("intersects_point_filter") && usedSpatiaExtras.includes("intersection_area")) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["spatial_extras"],
- message: `intersection_area ne peut être calculé que sur une géométrie surfacique, or le filtre spatial utilisé est ponctuel. Retirez intersection_area de spatial_extras.`,
- });
+ for (const extra of input.spatial_extras.filter(spatialExtraRequiresFilter)) {
+ const { incompatibleFilter, incompatibleReason } = FILTER_DEPENDENT_EXTRA_RULES[extra];
+
+ if (usedSpatialFilters.length === 0) {
+ const compatibleFilters = GPF_GET_FEATURES_SPATIAL_FILTER_KEYS
+ .filter((key) => key !== incompatibleFilter)
+ .map((name) => `\`${name}\``)
+ .join(", ")
+ .replace(/, ([^,]*)$/, ' ou $1');
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["spatial_extras"],
+ message: `\`${extra}\` exige un filtre spatial (${compatibleFilters}) : ajoutez un de ces filtres, ou retirez \`${extra}\` de \`spatial_extras\`.`,
+ });
+ } else if (usedSpatialFilters.includes(incompatibleFilter)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["spatial_extras"],
+ message: `\`${extra}\` ${incompatibleReason}, ou retirez \`${extra}\` de \`spatial_extras\`.`,
+ });
+ }
}
}
@@ -192,7 +227,8 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({
"chaque objet",
GPF_SPATIAL_EXTRAS_DOCNAMES,
"`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n"+
- "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre."
+ "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n"+
+ "`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`."
)),
});
diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts
index fb8f7e99..3c130190 100644
--- a/src/wfs/spatialExtras.ts
+++ b/src/wfs/spatialExtras.ts
@@ -151,7 +151,8 @@ export function prepareSpatialContext(input: FeatureCollectionPostProcessInput,
return context;
}
- // The existence of a spatial filter has already been validated, hence the final "!".
+ // The input schema guarantees a spatial filter when a filter-dependent extra is
+ // requested (`assertSpatialExtraSpatialFilterConsistency`), hence the final "!".
// In case of internal error, the associated spatial_extra will be set to null.
const spatialFilter = getSpatialFilter(input)!;
@@ -302,7 +303,8 @@ export function deriveFromGeometry(geometry: unknown, input: FeatureCollectionPo
return ret;
}
- // The existence of a spatial filter has already been validated, hence the final "!".
+ // The input schema guarantees a spatial filter when a filter-dependent extra is
+ // requested (`assertSpatialExtraSpatialFilterConsistency`), hence the final "!".
// In case of internal error, the associated spatial_extra will be set to null.
const spatialFilter = getSpatialFilter(input)!;
diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts
index 655cf725..6924ae37 100644
--- a/test/tools/wfs/getFeatures.test.ts
+++ b/test/tools/wfs/getFeatures.test.ts
@@ -380,6 +380,74 @@ describe("Test GpfGetFeaturesTool", () => {
expect(mockFetchJSONPost).not.toHaveBeenCalled();
});
+ describe("filter-dependent spatial_extras", () => {
+ const FILTER_DEPENDENT_EXTRAS = ["distance_to_filter", "intersection_area"] as const;
+
+ async function callWith(args: Record) {
+ const tool = new GpfGetFeaturesTool();
+ return tool.toolCall({
+ params: {
+ name: "gpf_get_features",
+ arguments: { typename: COMMUNE_TYPENAME, ...args },
+ },
+ });
+ }
+
+ it.each(FILTER_DEPENDENT_EXTRAS)("should reject %s without a spatial filter as invalid tool parameters", async (extra) => {
+ const response = await callWith({ spatial_extras: [extra] });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: [
+ expect.objectContaining({
+ code: "custom",
+ name: "spatial_extras",
+ detail: expect.stringContaining(`\`${extra}\` exige un filtre spatial`),
+ }),
+ ],
+ });
+ expect(mockGetFeatureType).not.toHaveBeenCalled();
+ expect(mockFetchJSONPost).not.toHaveBeenCalled();
+ });
+
+ it.each(FILTER_DEPENDENT_EXTRAS)("should reject %s with intersects_point_filter as invalid tool parameters", async (extra) => {
+ const response = await callWith({
+ intersects_point_filter: { lon: 2.3, lat: 48.8 },
+ spatial_extras: [extra],
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: [
+ expect.objectContaining({
+ code: "custom",
+ name: "spatial_extras",
+ detail: expect.stringContaining("intersects_point_filter"),
+ }),
+ ],
+ });
+ expect(mockGetFeatureType).not.toHaveBeenCalled();
+ expect(mockFetchJSONPost).not.toHaveBeenCalled();
+ });
+
+ it("should accept both extras with any other spatial filter", () => {
+ for (const filter of [
+ { bbox_filter: { west: 2.1, south: 48.7, east: 2.5, north: 48.9 } },
+ { dwithin_point_filter: { lon: 2.3, lat: 48.8, distance_m: 500 } },
+ { intersects_feature_filter: { typename: "ADMINEXPRESS-COG.LATEST:departement", feature_id: "departement.1" } },
+ { travel_time_filter: { lon: 2.3, lat: 48.8, minutes: 10, profile: "pedestrian" } },
+ ]) {
+ expect(() => gpfGetFeaturesInputSchema.parse({
+ typename: COMMUNE_TYPENAME,
+ spatial_extras: [...FILTER_DEPENDENT_EXTRAS],
+ ...filter,
+ })).not.toThrow();
+ }
+ });
+ });
+
it("should reject legacy inputs removed from the public schema", async () => {
const tool = new GpfGetFeaturesTool();
const response = await tool.toolCall({
diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts
index de7f6594..35e43b86 100644
--- a/test/wfs/queryPreparation.test.ts
+++ b/test/wfs/queryPreparation.test.ts
@@ -5,10 +5,7 @@ import type { GpfFeatureType } from "../../src/wfs/catalog";
import { compileQueryParts } from "../../src/wfs/queryPreparation";
import type { GpfGetFeaturesInput, GpfCountFeaturesInput } from "../../src/wfs/schema";
import { geometryToEwkt } from "../../src/wfs/geometry";
-import {
- GPF_SPATIAL_EXTRAS_REQUIRING_FILTER,
- queryIsGetFeaturesInput,
-} from "../../src/wfs/schema";
+import { queryIsGetFeaturesInput } from "../../src/wfs/schema";
describe("gpfGetFeatures/queryPreparation", () => {
const featureType: OgcCollectionSchema = {
@@ -209,13 +206,6 @@ describe("gpfGetFeatures/queryPreparation", () => {
);
});
- it.each(GPF_SPATIAL_EXTRAS_REQUIRING_FILTER)("should reject %s without any spatial filter", (spatialExtra) => {
- expect(() => compileQueryParts({
- ...baseInput,
- spatial_extras: [spatialExtra],
- }, wrappedFeatureType)).toThrow(`Impossible de demander ${spatialExtra} sans spécifier de filtre géométrique`);
- });
-
it("should build sortBy from structured order_by", () => {
const compiled = compileQueryParts({
...baseInput,
From 8aa7c32fbca56a50c462ea42cde788e355434047 Mon Sep 17 00:00:00 2001
From: esgn <5435148+esgn@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:34:48 +0200
Subject: [PATCH 3/4] fix(properties): skip spatial_extras validation when none
is requested
buildPropertyNameWithGeometry always passes an empty spatial_extras list, and
the `!spatial_extras` guard let it through: the by-id layer tool and the proxy
by-id resolve then read the geometry format and threw on an unlisted or missing
one. Return early on an empty list, so already minted by-id layer URLs do not
depend on the catalog format values.
---
src/wfs/properties.ts | 4 +++-
test/wfs/properties.test.ts | 24 ++++++++++++++++++++++++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts
index 32bc22b2..ed583e2e 100644
--- a/src/wfs/properties.ts
+++ b/src/wfs/properties.ts
@@ -132,7 +132,9 @@ export function validateSelectProperty(featureType: GpfFeatureType, propertyName
// --- Spatial Extras Validation ---
function validateSpatialExtras(featureType: GpfFeatureType, geometryName: string, spatial_extras?: SpatialExtraOptions[]) {
- if (!spatial_extras) {
+ // Nothing to validate without extras: do not read the geometry format, which
+ // cartographic callers (always `[]`) never need.
+ if (!spatial_extras?.length) {
return;
}
const geometryType = getGeometryType(featureType, geometryName);
diff --git a/test/wfs/properties.test.ts b/test/wfs/properties.test.ts
index 1028c398..c213152b 100644
--- a/test/wfs/properties.test.ts
+++ b/test/wfs/properties.test.ts
@@ -6,6 +6,7 @@ import {
resolveNonGeometryProperty,
validateSelectProperty,
buildPropertyName,
+ buildPropertyNameWithGeometry,
} from "../../src/wfs/properties";
// --- Test fixtures ---
@@ -278,3 +279,26 @@ describe("buildPropertyName", () => {
.toEqual("name,geometry");
});
});
+
+describe("buildPropertyNameWithGeometry", () => {
+ // Cartographic callers (by-id layer tool, proxy by-id resolve) request no
+ // spatial_extras, so the geometry format must not be read: an unlisted or
+ // missing format must not break already minted by-id layer URLs.
+ it.each([
+ ["an unlisted format", { format: "geometry-multisurface", "x-ogc-role": "primary-geometry" }],
+ ["no format", { "x-ogc-role": "primary-geometry" }],
+ ])("should not read the geometry format when the geometry has %s", (_label, geometry) => {
+ const collection: OgcCollectionSchema = {
+ ...singleGeometryCollection,
+ properties: {
+ ...singleGeometryCollection.properties,
+ geometry: geometry as unknown as OgcCollectionProperty,
+ },
+ };
+
+ expect(buildPropertyNameWithGeometry(asFeatureType("SINGLE:GEO", collection)))
+ .toEqual("geometry,name,population");
+ expect(buildPropertyNameWithGeometry(asFeatureType("SINGLE:GEO", collection), ["name"]))
+ .toEqual("name,geometry");
+ });
+});
From 66bb1f0e99beebc3db6d4891d2bf50213c192bcb Mon Sep 17 00:00:00 2001
From: esgn <5435148+esgn@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:41:14 +0200
Subject: [PATCH 4/4] fix(gpf_get_features): tell the LLM that spatial_extras
only cover returned features
- tool and spatial_extras descriptions: extras are computed after the query, on
the returned features only, and cannot be used in where/order_by; a ranking
or a sum needs numberReturned == numberMatched
- "property does not exist" error: explain when the name is a spatial extra
(message only, a real catalog column with that name is still accepted)
---
docs/mcp-tools.md | 5 +++--
src/tools/GpfGetFeaturesTool.ts | 2 ++
src/wfs/properties.ts | 8 ++++++--
src/wfs/schema.ts | 3 ++-
test/wfs/properties.test.ts | 10 ++++++++++
test/wfs/queryPreparation.test.ts | 9 +++++++++
6 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index 76a06dbd..193f9af6 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -1095,6 +1095,7 @@ Lecture d’objets GPF
```
Interroge un type GPF et renvoie des résultats structurés (propriétés attributaires ; les géométries ne sont pas incluses). Pour obtenir une couche cartographiable, utiliser `gpf_get_features_layer`.
Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial.
+Utiliser `spatial_extras` pour obtenir des mesures calculées sur la géométrie (`centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`). Elles portent uniquement sur les objets renvoyés.
Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`.
Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`.
Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.
@@ -1117,7 +1118,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
| `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. |
| `order_by` | array | non | Liste ordonnée des critères de tri. |
| `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. |
-| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
+| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.
`bbox` est la boîte englobante de la géométrie.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.
`distance_to_filter` et `intersection_area` exigent un filtre spatial.
Les `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, le plus proche) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit`.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. |
| `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. |
| `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. |
@@ -1391,7 +1392,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
]
},
"default": [],
- "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu."
+ "description": "Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter` et `intersection_area`, aucun par défaut.\n`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie.\n`bbox` est la boîte englobante de la géométrie.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n`distance_to_filter` et `intersection_area` exigent un filtre spatial.\nLes `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, le plus proche) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit`.\nSi l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.\nSinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu."
}
},
"required": [
diff --git a/src/tools/GpfGetFeaturesTool.ts b/src/tools/GpfGetFeaturesTool.ts
index 719e3e07..f1a83e0c 100644
--- a/src/tools/GpfGetFeaturesTool.ts
+++ b/src/tools/GpfGetFeaturesTool.ts
@@ -17,6 +17,7 @@ import {
type GpfGetFeaturesInput,
gpfGetFeaturesPublishedInputSchema,
GPF_SPATIAL_FILTER_DOCNAMES,
+ GPF_SPATIAL_EXTRAS_DOCNAMES,
} from "../wfs/schema.js";
import logger from "../logger.js";
@@ -25,6 +26,7 @@ import logger from "../logger.js";
const GPF_GET_FEATURES_TOOL_DESCRIPTION = [
"Interroge un type GPF et renvoie des résultats structurés (propriétés attributaires ; les géométries ne sont pas incluses). Pour obtenir une couche cartographiable, utiliser `gpf_get_features_layer`.",
`Utiliser \`select\` pour choisir les propriétés, \`where\` pour filtrer, \`order_by\` pour trier et un filtre spatial dédié (${GPF_SPATIAL_FILTER_DOCNAMES}) pour le spatial.`,
+ `Utiliser \`spatial_extras\` pour obtenir des mesures calculées sur la géométrie (${GPF_SPATIAL_EXTRAS_DOCNAMES}). Elles portent uniquement sur les objets renvoyés.`,
"Exemple attributaire : `where=[{ property: \"code_insee\", operator: \"eq\", value: \"75056\" }]`.",
"Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`.",
"Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.",
diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts
index ed583e2e..18090fd0 100644
--- a/src/wfs/properties.ts
+++ b/src/wfs/properties.ts
@@ -10,7 +10,7 @@
import type { GpfFeatureType } from "./catalog.js";
import type { OgcCollectionProperty } from "@ignfab/gpf-schema-store";
import type { Geometry } from "geojson";
-import type { SpatialExtraOptions } from "./schema.js";
+import { GPF_GET_FEATURES_SPATIAL_EXTRAS, type SpatialExtraOptions } from "./schema.js";
// --- Geometry Resolution ---
@@ -99,8 +99,12 @@ export function resolveNonGeometryProperty(featureType: GpfFeatureType, property
const nonGeometryProperties = (Object.entries(featureType.schema.properties))
.filter(([_propertyName, property]) => Boolean((property as OgcCollectionProperty).type))
.map(([propertyName]) => propertyName);
+ // A spatial extra name is a likely mix-up (e.g. `order_by: area`): say what it is.
+ const spatialExtraHint = (GPF_GET_FEATURES_SPATIAL_EXTRAS as readonly string[]).includes(propertyName)
+ ? ` \`${propertyName}\` désigne un élément calculé par \`spatial_extras\` dans \`gpf_get_features\`, pas une propriété du type : il n'est utilisable ni dans \`select\`, ni dans \`where\`, ni dans \`order_by\`.`
+ : "";
throw new Error(
- `La propriété '${propertyName}' n'existe pas pour '${featureType.typename}'. ` +
+ `La propriété '${propertyName}' n'existe pas pour '${featureType.typename}'.${spatialExtraHint} ` +
`Propriétés non géométriques disponibles : ${nonGeometryProperties.join(", ")}. ` +
`Appelle \`gpf_describe_type\` pour obtenir la signification de ces propriétés.`,
);
diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts
index 7465d711..6502bfd6 100644
--- a/src/wfs/schema.ts
+++ b/src/wfs/schema.ts
@@ -228,7 +228,8 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({
GPF_SPATIAL_EXTRAS_DOCNAMES,
"`distance_to_filter` est la distance (en m) entre la géométrie de l'objet renvoyé et le centroïde du filtre spatial (le point de départ dans le cas de `travel_time_filter`).\n"+
"`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial. L'objet et le filtre doivent être surfaciques, sinon la valeur est `null` ; `0` signifie que l'objet ne recouvre pas le filtre.\n"+
- "`distance_to_filter` et `intersection_area` exigent un filtre spatial autre que `intersects_point_filter`."
+ "`distance_to_filter` et `intersection_area` exigent un filtre spatial.\n"+
+ "Les `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, le plus proche) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit`."
)),
});
diff --git a/test/wfs/properties.test.ts b/test/wfs/properties.test.ts
index c213152b..af0b00cc 100644
--- a/test/wfs/properties.test.ts
+++ b/test/wfs/properties.test.ts
@@ -156,6 +156,16 @@ describe("getGeometryName", () => {
});
describe("resolveNonGeometryProperty", () => {
+ it("should accept a real catalog property named like a spatial extra", () => {
+ const collection: OgcCollectionSchema = {
+ ...singleGeometryCollection,
+ properties: { ...singleGeometryCollection.properties, area: populationProperty },
+ };
+
+ expect(resolveNonGeometryProperty(asFeatureType("SINGLE:GEO", collection), "area", "Error message"))
+ .toEqual(populationProperty);
+ });
+
it("should return the property when it is non-geometric", () => {
const result = resolveNonGeometryProperty(
asFeatureType("SINGLE:GEO", singleGeometryCollection),
diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts
index 35e43b86..4459a0a0 100644
--- a/test/wfs/queryPreparation.test.ts
+++ b/test/wfs/queryPreparation.test.ts
@@ -48,6 +48,15 @@ describe("gpfGetFeatures/queryPreparation", () => {
spatial_extras: []
};
+ it.each<[string, Partial]>([
+ ["select", { select: ["area"] }],
+ ["where", { where: [{ property: "area", operator: "gt", value: "1000" }] }],
+ ["order_by", { order_by: [{ property: "area", direction: "desc" }] }],
+ ])("should explain that a spatial extra is not a property when used in %s", (_clause, clause) => {
+ expect(() => compileQueryParts({ ...baseInput, ...clause }, wrappedFeatureType))
+ .toThrow("`area` désigne un élément calculé par `spatial_extras` dans `gpf_get_features`, pas une propriété du type");
+ });
+
it("should compile where clauses", () => {
const compiled = compileQueryParts({
...baseInput,