diff --git a/package-lock.json b/package-lock.json index d19c7cb..479f9e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2200,9 +2200,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", - "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2902,9 +2902,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.343", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", - "integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true, "license": "ISC" }, @@ -3178,9 +3178,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz", + "integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==", "license": "MIT", "peer": true, "dependencies": { diff --git a/src/helpers/wfs_engine/byId.ts b/src/helpers/wfs_engine/byId.ts new file mode 100644 index 0000000..0a98303 --- /dev/null +++ b/src/helpers/wfs_engine/byId.ts @@ -0,0 +1,119 @@ +/** + * Execution helpers for exact WFS feature lookup by `feature_id`. + * + * This module runs the structured WFS by-id flow independently from MCP tool + * concerns such as schema exposure and response formatting. + */ + +import type { Collection } from "@ignfab/gpf-schema-store"; + +import { getFeatureType, fetchFeatureCollection } from "./execution.js"; +import { compileSelectProperty, getGeometryProperty } from "./compile.js"; +import { buildGetFeatureByIdRequest } from "./request.js"; +import { attachFeatureRefs } from "./response.js"; + +export type GetFeatureByIdExecutionInput = { + typename: string; + feature_id: string; + select?: string[]; +}; + +type BuildPropertyNameInput = { + result_type: "results" | "request"; + select?: string[]; +}; + +/** + * Builds the optional `propertyName` request parameter from `select`. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param input Normalized tool input. + * @returns A comma-separated property list, or `undefined` when all properties should be returned. + */ +export function buildPropertyName( + featureType: Collection, + input: BuildPropertyNameInput, +) { + if (!input.select || input.select.length === 0) { + return undefined; + } + + const geometryProperty = getGeometryProperty(featureType); + const selectedProperties = input.select.map((propertyName) => + compileSelectProperty(featureType, geometryProperty, propertyName), + ); + + if (input.result_type === "request") { + return [...selectedProperties, geometryProperty.name].join(","); + } + + return selectedProperties.join(","); +} + +/** + * Executes the structured WFS by-id flow for `result_type="results"`. + * + * This function: + * - loads the feature type from the embedded catalog + * - builds the optional `propertyName` selection + * - executes the WFS request for the requested `feature_id` + * - enforces strict cardinality on the returned FeatureCollection + * - attaches reusable `feature_ref` metadata to the final response + * + * Tool-specific concerns such as MCP schema exposure and request-preview + * formatting remain outside this helper. + * + * This helper is intentionally scoped to the `results` path only. MCP-specific + * request preview assembly remains in the tool layer. + * + * @param input Normalized by-id execution input for the `results` flow. + * @returns A transformed FeatureCollection containing exactly one feature. + */ +export async function executeGetFeatureById( + input: GetFeatureByIdExecutionInput, +) { + const featureType: Collection = await getFeatureType(input.typename); + const propertyName = buildPropertyName(featureType, { + result_type: "results", + select: input.select, + }); + const request = buildGetFeatureByIdRequest( + input.typename, + input.feature_id, + propertyName, + ); + + const featureCollection = await fetchFeatureCollection(request); + + if (!Array.isArray(featureCollection?.features)) { + throw new Error("Le service WFS n'a pas retourné de collection d'objets exploitable."); + } + + if (featureCollection.features.length === 0) { + throw new Error(`Le feature '${input.feature_id}' est introuvable dans '${input.typename}'.`); + } + + if (featureCollection.features.length > 1) { + throw new Error( + `Le feature '${input.feature_id}' dans '${input.typename}' devrait être unique, mais ${featureCollection.features.length} objets ont été retournés.` + ); + } + + const [firstFeature] = featureCollection.features; + + if (firstFeature?.id !== input.feature_id) { + throw new Error( + `Le service WFS a retourné l'identifiant '${String(firstFeature?.id)}' au lieu de '${input.feature_id}'.` + ); + } + + const singleFeatureCollection = { + ...featureCollection, + features: [firstFeature], + totalFeatures: 1, + numberReturned: 1, + numberMatched: 1, + }; + + return attachFeatureRefs(singleFeatureCollection, input.typename); +} diff --git a/src/helpers/wfs_engine/execution.ts b/src/helpers/wfs_engine/execution.ts new file mode 100644 index 0000000..6b950e5 --- /dev/null +++ b/src/helpers/wfs_engine/execution.ts @@ -0,0 +1,54 @@ +import type { CompiledRequest } from "./request.js"; +import { wfsClient } from "../../gpf/wfs-schema-catalog.js"; +import { fetchJSONPost } from "../../helpers/http.js"; + +/** + * Shared WFS execution helpers for the structured WFS engine. + * + * This module centralizes catalog lookup, compiled request execution, and a few + * low-level response helpers reused by MCP WFS tools. + */ + +/** + * Loads a WFS feature type description from the embedded catalog. + * + * @param typename Exact WFS typename to load from the embedded schema store. + * @returns The matching feature type description. + */ +export async function getFeatureType(typename: string) { + return wfsClient.getFeatureType(typename); +} + +/** + * Executes a compiled WFS request as POST and returns the JSON FeatureCollection. + * + * @param request Compiled request split into query-string parameters and POST body. + * @returns The parsed JSON response returned by the WFS endpoint. + */ +export async function fetchFeatureCollection(request: CompiledRequest) { + const url = `${request.url}?${new URLSearchParams(request.query).toString()}`; + return fetchJSONPost(url, request.body, { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }); +} + +/** + * Extracts a result count from a WFS response, preferring `numberMatched`. + * Explicitly rejects responses that do not provide a usable total. + * + * @param featureCollection Parsed WFS response object. + * @returns The total number of matching features. + */ +export function getMatchedFeatureCount(featureCollection: Record) { + if (typeof featureCollection.numberMatched === "number") { + return featureCollection.numberMatched; + } + if (featureCollection.numberMatched === "unknown") { + throw new Error("Le service WFS a renvoyé un comptage indéterminé (numberMatched=\"unknown\")."); + } + if (typeof featureCollection.totalFeatures === "number") { + return featureCollection.totalFeatures; + } + throw new Error("Le service WFS n'a pas retourné de comptage exploitable"); +} \ No newline at end of file diff --git a/src/helpers/wfs_engine/features.ts b/src/helpers/wfs_engine/features.ts new file mode 100644 index 0000000..bbe914f --- /dev/null +++ b/src/helpers/wfs_engine/features.ts @@ -0,0 +1,181 @@ +import type { Collection } from "@ignfab/gpf-schema-store"; + +import logger from "../../logger.js"; +import { + compileQueryParts, + geometryToEwkt, + getGeometryProperty, + getSpatialFilter, + type CompiledQuery, + type ResolvedFeatureGeometryRef, +} from "./compile.js"; +import { + fetchFeatureCollection, + getFeatureType, + getMatchedFeatureCount, +} from "./execution.js"; +import { + buildMainRequest, + buildReferenceGeometryRequest, + type CompiledRequest, +} from "./request.js"; +import { attachFeatureRefs } from "./response.js"; +import type { GpfWfsGetFeaturesInput } from "./schema.js"; + +/** + * Shared execution helpers for structured WFS feature search. + * + * This module owns the WFS-side execution flow for `gpf_wfs_get_features`: + * request preparation, optional reference-geometry lookup, query execution, + * hit counting, and FeatureCollection post-processing. + */ + +// --- Types --- + +export type PreparedGetFeaturesRequest = { + compiled: CompiledQuery; + request: CompiledRequest; +}; + +// --- Validation --- + +/** + * Rejects `intersects_feature` requests that target the same typename. + * + * In that configuration the predicate may legitimately match multiple + * features, so callers must switch to the by-id tool instead. + * + * @param input Normalized tool input. + */ +export function ensureIntersectsFeatureTargetsOtherTypename( + input: GpfWfsGetFeaturesInput, +) { + if ( + input.spatial_operator === "intersects_feature" && + input.intersects_feature_typename !== undefined && + input.typename === input.intersects_feature_typename + ) { + throw new Error( + "Le filtre `intersects_feature` sur le même `typename` retourne potentiellement plusieurs objets. " + + "Utiliser `gpf_wfs_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_id }` pour cibler exactement un objet.", + ); + } +} + +// --- Reference Geometry --- + +/** + * Resolves the geometry of a reference feature when `intersects_feature` is used, + * then converts it to EWKT for CQL compilation. + * + * This helper currently reads the first feature returned by the reference + * lookup. It ensures that a feature exists and exposes a usable geometry, but + * does not enforce strict uniqueness or exact `id` matching beyond what the WFS + * request itself guarantees. + * + * @param input Normalized tool input. + * @returns The resolved reference geometry, or `undefined` when no reference feature is needed. + */ +export async function resolveIntersectsFeatureGeometry( + input: GpfWfsGetFeaturesInput, +): Promise { + const spatialFilter = getSpatialFilter(input); + if (!spatialFilter || spatialFilter.operator !== "intersects_feature") { + return undefined; + } + + const referenceFeatureType = await getFeatureType(spatialFilter.typename); + const referenceGeometryProperty = getGeometryProperty(referenceFeatureType); + const request = buildReferenceGeometryRequest( + spatialFilter.typename, + spatialFilter.feature_id, + referenceGeometryProperty.name, + ); + const featureCollection = await fetchFeatureCollection(request); + const referenceFeature = Array.isArray(featureCollection?.features) + ? featureCollection.features[0] + : undefined; + + if (!referenceFeature) { + throw new Error( + `Le feature de référence '${spatialFilter.feature_id}' est introuvable dans '${spatialFilter.typename}'.`, + ); + } + if (!referenceFeature?.geometry) { + throw new Error( + `Le feature de référence '${spatialFilter.feature_id}' n'a pas de géométrie exploitable.`, + ); + } + + return { + typename: spatialFilter.typename, + feature_id: spatialFilter.feature_id, + geometry_ewkt: geometryToEwkt(referenceFeature.geometry), + }; +} + +// --- Request Preparation --- + +/** + * Prepares the main WFS request for `gpf_wfs_get_features`. + * + * This includes upfront validation of unsupported same-typename + * `intersects_feature` requests, feature type lookup, optional + * reference-geometry resolution, query compilation, and request assembly. + * + * @param input Normalized tool input. + * @returns The compiled query fragments and final WFS request. + */ +export async function prepareGetFeaturesRequest( + input: GpfWfsGetFeaturesInput, +): Promise { + ensureIntersectsFeatureTargetsOtherTypename(input); + + const featureType: Collection = await getFeatureType(input.typename); + const resolvedGeometryRef = await resolveIntersectsFeatureGeometry(input); + const compiled = compileQueryParts(input, featureType, resolvedGeometryRef); + const request = buildMainRequest(input, compiled); + + return { compiled, request }; +} + +// --- Execution --- + +/** + * Executes the structured WFS search flow for `result_type="results"` and `hits`. + * + * This function prepares the request, executes it against the live WFS, then + * either extracts a hit count or attaches `feature_ref` metadata to the result + * FeatureCollection. + * + * @param input Normalized tool input. + * @returns Either a hit-count payload or a transformed FeatureCollection. + */ +export async function executeGetFeatures(input: GpfWfsGetFeaturesInput) { + const { compiled, request } = await prepareGetFeaturesRequest(input); + + let featureCollection: any; + try { + logger.info( + `[gpf_wfs_get_features] POST ${request.url}?${new URLSearchParams(request.query).toString()}`, + ); + featureCollection = await fetchFeatureCollection(request); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(`Illegal property name: ${compiled.geometryProperty.name}`)) { + throw new Error( + `Le champ géométrique '${compiled.geometryProperty.name}' issu du catalogue embarqué est rejeté par le WFS live pour '${input.typename}'. Le catalogue embarqué est probablement désynchronisé. Détail : ${message}`, + ); + } + throw error; + } + + if (input.result_type === "hits") { + return { + result_type: "hits" as const, + totalFeatures: getMatchedFeatureCount(featureCollection), + }; + } + + return attachFeatureRefs(featureCollection, input.typename); +} diff --git a/src/tools/GpfWfsGetFeatureByIdTool.ts b/src/tools/GpfWfsGetFeatureByIdTool.ts index 59850ad..e3a0f7a 100644 --- a/src/tools/GpfWfsGetFeatureByIdTool.ts +++ b/src/tools/GpfWfsGetFeatureByIdTool.ts @@ -1,16 +1,24 @@ +/** + * MCP tool exposing exact WFS feature lookup by `feature_id`. + * + * The tool keeps MCP-facing concerns such as schema exposure, compact response + * formatting, and request-preview output. The `results` execution flow itself + * is delegated to the structured WFS engine. + */ + import { MCPTool } from "mcp-framework"; import type { Collection } from "@ignfab/gpf-schema-store"; import { z } from "zod"; import { wfsClient } from "../gpf/wfs-schema-catalog.js"; -import { fetchJSONPost } from "../helpers/http.js"; -import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; -import { compileSelectProperty, getGeometryProperty } from "../helpers/wfs_engine/compile.js"; -import { buildGetFeatureByIdRequest, type CompiledRequest } from "../helpers/wfs_engine/request.js"; -import { attachFeatureRefs } from "../helpers/wfs_engine/response.js"; +import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { buildPropertyName, executeGetFeatureById } from "../helpers/wfs_engine/byId.js"; +import { buildGetFeatureByIdRequest } from "../helpers/wfs_engine/request.js"; import { gpfWfsGetFeaturesRequestOutputSchema } from "../helpers/wfs_engine/schema.js"; +// --- Schema --- + const gpfWfsGetFeatureByIdInputSchema = z.object({ typename: z .string() @@ -33,6 +41,8 @@ const gpfWfsGetFeatureByIdInputSchema = z.object({ .describe("Liste des propriétés non géométriques à renvoyer. Quand `result_type=\"request\"`, la géométrie est automatiquement ajoutée."), }).strict(); +// --- Types --- + type GpfWfsGetFeatureByIdInput = z.infer; type PublishedInputSchema = { @@ -43,6 +53,8 @@ type PublishedInputSchema = { const gpfWfsGetFeatureByIdPublishedInputSchema = generatePublishedInputSchema(gpfWfsGetFeatureByIdInputSchema) as PublishedInputSchema; +// --- Tool --- + class GpfWfsGetFeatureByIdTool extends MCPTool { name = "gpf_wfs_get_feature_by_id"; title = "Lecture d’un objet WFS par identifiant"; @@ -87,52 +99,6 @@ class GpfWfsGetFeatureByIdTool extends MCPTool { return super.createSuccessResponse(data); } - /** - * Loads a WFS feature type description from the embedded catalog. - * - * @param typename Exact WFS typename to load from the embedded schema store. - * @returns The matching feature type description. - */ - protected async getFeatureType(typename: string) { - return wfsClient.getFeatureType(typename); - } - - /** - * Executes a compiled WFS request as POST and returns the JSON FeatureCollection. - * - * @param request Compiled request split into query-string parameters and POST body. - * @returns The parsed JSON response returned by the WFS endpoint. - */ - protected async fetchFeatureCollection(request: CompiledRequest) { - const url = `${request.url}?${new URLSearchParams(request.query).toString()}`; - return fetchJSONPost(url, request.body, { - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }); - } - - /** - * Builds the optional `propertyName` request parameter from `select`. - * - * @param featureType Feature type definition loaded from the embedded catalog. - * @param input Normalized tool input. - * @returns A comma-separated property list, or `undefined` when all properties should be returned. - */ - protected buildPropertyName(featureType: Collection, input: GpfWfsGetFeatureByIdInput) { - if (!input.select || input.select.length === 0) { - return undefined; - } - - const geometryProperty = getGeometryProperty(featureType); - const selectedProperties = input.select.map((propertyName) => compileSelectProperty(featureType, geometryProperty, propertyName)); - - if (input.result_type === "request") { - return [...selectedProperties, geometryProperty.name].join(","); - } - - return selectedProperties.join(","); - } - /** * Orchestrates the by-id execution flow: * schema lookup -> request compilation -> optional request output -> WFS execution -> cardinality validation. @@ -141,11 +107,16 @@ class GpfWfsGetFeatureByIdTool extends MCPTool { * @returns Either a compiled request or a transformed FeatureCollection containing one feature. */ async execute(input: GpfWfsGetFeatureByIdInput) { - const featureType: Collection = await this.getFeatureType(input.typename); - const propertyName = this.buildPropertyName(featureType, input); - const request = buildGetFeatureByIdRequest(input.typename, input.feature_id, propertyName); - if (input.result_type === "request") { + // Keep request preview assembly local to the tool: this branch exposes + // MCP-facing debug output rather than executing the by-id results flow. + const featureType: Collection = await wfsClient.getFeatureType(input.typename); + const propertyName = buildPropertyName(featureType, { + result_type: input.result_type, + select: input.select, + }); + const request = buildGetFeatureByIdRequest(input.typename, input.feature_id, propertyName); + return { result_type: "request" as const, method: request.method, @@ -156,33 +127,11 @@ class GpfWfsGetFeatureByIdTool extends MCPTool { }; } - const featureCollection = await this.fetchFeatureCollection(request); - if (!Array.isArray(featureCollection?.features)) { - throw new Error("Le service WFS n'a pas retourné de collection d'objets exploitable."); - } - - if (featureCollection.features.length === 0) { - throw new Error(`Le feature '${input.feature_id}' est introuvable dans '${input.typename}'.`); - } - - if (featureCollection.features.length > 1) { - throw new Error(`Le feature '${input.feature_id}' dans '${input.typename}' devrait être unique, mais ${featureCollection.features.length} objets ont été retournés.`); - } - - const [firstFeature] = featureCollection.features; - if (firstFeature?.id !== input.feature_id) { - throw new Error(`Le service WFS a retourné l'identifiant '${String(firstFeature?.id)}' au lieu de '${input.feature_id}'.`); - } - - const singleFeatureCollection = { - ...featureCollection, - features: [firstFeature], - totalFeatures: 1, - numberReturned: 1, - numberMatched: 1, - }; - - return attachFeatureRefs(singleFeatureCollection, input.typename); + return executeGetFeatureById({ + typename: input.typename, + feature_id: input.feature_id, + select: input.select, + }); } } diff --git a/src/tools/GpfWfsGetFeaturesTool.ts b/src/tools/GpfWfsGetFeaturesTool.ts index 1a1df3c..a72d466 100644 --- a/src/tools/GpfWfsGetFeaturesTool.ts +++ b/src/tools/GpfWfsGetFeaturesTool.ts @@ -1,13 +1,10 @@ import { MCPTool } from "mcp-framework"; -import type { Collection } from "@ignfab/gpf-schema-store"; -import { wfsClient } from "../gpf/wfs-schema-catalog.js"; -import { fetchJSONPost } from "../helpers/http.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { compileQueryParts, geometryToEwkt, getGeometryProperty, getSpatialFilter } from "../helpers/wfs_engine/compile.js"; -import { buildMainRequest, buildReferenceGeometryRequest, type CompiledRequest } from "../helpers/wfs_engine/request.js"; -import { attachFeatureRefs } from "../helpers/wfs_engine/response.js"; +import { + executeGetFeatures, + prepareGetFeaturesRequest, +} from "../helpers/wfs_engine/features.js"; import { gpfWfsGetFeaturesHitsOutputSchema, gpfWfsGetFeaturesInputSchema, @@ -16,6 +13,15 @@ import { gpfWfsGetFeaturesRequestOutputSchema, } from "../helpers/wfs_engine/schema.js"; +/** + * MCP tool exposing structured WFS feature search. + * + * The tool remains responsible for MCP schema exposure and response formatting. + * WFS request preparation and execution live in the structured WFS engine. + */ + +// --- Tool --- + class GpfWfsGetFeaturesTool extends MCPTool { name = "gpf_wfs_get_features"; title = "Lecture d’objets WFS"; @@ -29,7 +35,7 @@ class GpfWfsGetFeaturesTool extends MCPTool { "Exemple réutilisation : `spatial_operator=\"intersects_feature\"` avec `intersects_feature_typename` et `intersects_feature_id` issus d'une `feature_ref`.", "⚠️ Quand `typename` et `intersects_feature_typename` sont identiques, utiliser `gpf_wfs_get_feature_by_id` pour récupérer exactement l'objet ciblé.", "**OBLIGATOIRE : toujours appeler `gpf_wfs_describe_type` avant ce tool, sauf si `gpf_wfs_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**", - "Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_wfs_describe_type` **provoquera une erreur.**" + "Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_wfs_describe_type` **provoquera une erreur.**", ].join("\n"); schema = gpfWfsGetFeaturesInputSchema; @@ -81,140 +87,39 @@ class GpfWfsGetFeaturesTool extends MCPTool { } /** - * Loads a WFS feature type description from the embedded catalog. - * - * @param typename Exact WFS typename to load from the embedded schema store. - * @returns The matching feature type description. - */ - protected async getFeatureType(typename: string) { - return wfsClient.getFeatureType(typename); - } - - /** - * Executes a compiled WFS request as POST and returns the JSON FeatureCollection. - * - * @param request Compiled request split into query-string parameters and POST body. - * @returns The parsed JSON response returned by the WFS endpoint. - */ - protected async fetchFeatureCollection(request: CompiledRequest) { - const url = `${request.url}?${new URLSearchParams(request.query).toString()}`; - return fetchJSONPost(url, request.body, { - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }); - } - - /** - * Extracts a result count from a WFS response, preferring `numberMatched`. - * Explicitly rejects responses that do not provide a usable total. - * - * @param featureCollection Parsed WFS response object. - * @returns The total number of matching features. - */ - protected getMatchedFeatureCount(featureCollection: Record) { - if (typeof featureCollection.numberMatched === "number") { - return featureCollection.numberMatched; - } - if (featureCollection.numberMatched === "unknown") { - throw new Error("Le service WFS a renvoyé un comptage indéterminé (numberMatched=\"unknown\")."); - } - if (typeof featureCollection.totalFeatures === "number") { - return featureCollection.totalFeatures; - } - throw new Error("Le service WFS n'a pas retourné de comptage exploitable"); - } - - /** - * Resolves the geometry of a reference feature when `intersects_feature` is used, - * then converts it to EWKT for CQL compilation. + * Formats the request-preview response returned by `result_type="request"`. * * @param input Normalized tool input. - * @returns The resolved reference geometry, or `undefined` when no reference feature is needed. + * @returns An MCP-compatible request preview payload. */ - protected async resolveIntersectsFeatureGeometry(input: GpfWfsGetFeaturesInput) { - const spatialFilter = getSpatialFilter(input); - if (!spatialFilter || spatialFilter.operator !== "intersects_feature") { - return undefined; - } - - const referenceFeatureType = await this.getFeatureType(spatialFilter.typename); - const referenceGeometryProperty = getGeometryProperty(referenceFeatureType); - const request = buildReferenceGeometryRequest( - spatialFilter.typename, - spatialFilter.feature_id, - referenceGeometryProperty.name - ); - const featureCollection = await this.fetchFeatureCollection(request); - const referenceFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : undefined; - if (!referenceFeature) { - throw new Error(`Le feature de référence '${spatialFilter.feature_id}' est introuvable dans '${spatialFilter.typename}'.`); - } - if (!referenceFeature?.geometry) { - throw new Error(`Le feature de référence '${spatialFilter.feature_id}' n'a pas de géométrie exploitable.`); - } + protected async buildRequestPreview(input: GpfWfsGetFeaturesInput) { + const { request } = await prepareGetFeaturesRequest(input); return { - typename: spatialFilter.typename, - feature_id: spatialFilter.feature_id, - geometry_ewkt: geometryToEwkt(referenceFeature.geometry), + result_type: "request" as const, + method: request.method, + url: request.url, + query: request.query, + body: request.body, + get_url: request.get_url ?? null, }; } /** - * Orchestrates the full tool execution flow: - * catalog lookup -> compilation -> WFS request -> response post-processing. + * Orchestrates the MCP-facing execution flow. + * + * Request previews stay in the tool because they are a tool-specific output + * mode, while the WFS-side preparation and execution live in `features.ts`. * * @param input Normalized tool input. * @returns Either a compiled request, a hit count, or a transformed FeatureCollection. */ async execute(input: GpfWfsGetFeaturesInput) { - if ( - input.spatial_operator === "intersects_feature" && - input.intersects_feature_typename !== undefined && - input.typename === input.intersects_feature_typename - ) { - throw new Error( - "Le filtre `intersects_feature` sur le même `typename` retourne potentiellement plusieurs objets. " + - "Utiliser `gpf_wfs_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_id }` pour cibler exactement un objet." - ); - } - - const featureType: Collection = await this.getFeatureType(input.typename); - const resolvedGeometryRef = await this.resolveIntersectsFeatureGeometry(input); - const compiled = compileQueryParts(input, featureType, resolvedGeometryRef); - const request = buildMainRequest(input, compiled); - if (input.result_type === "request") { - return { - result_type: "request" as const, - method: request.method, - url: request.url, - query: request.query, - body: request.body, - get_url: request.get_url ?? null, - }; - } - - let featureCollection: any; - try { - logger.info(`[gpf_wfs_get_features] POST ${request.url}?${new URLSearchParams(request.query).toString()}`); - featureCollection = await this.fetchFeatureCollection(request); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes(`Illegal property name: ${compiled.geometryProperty.name}`)) { - throw new Error(`Le champ géométrique '${compiled.geometryProperty.name}' issu du catalogue embarqué est rejeté par le WFS live pour '${input.typename}'. Le catalogue embarqué est probablement désynchronisé. Détail : ${message}`); - } - throw error; - } - - if (input.result_type === "hits") { - return { - result_type: "hits" as const, - totalFeatures: this.getMatchedFeatureCount(featureCollection), - }; + return this.buildRequestPreview(input); } - return attachFeatureRefs(featureCollection, input.typename); + return executeGetFeatures(input); } } diff --git a/test/tools/wfs/getFeatureById.test.ts b/test/tools/wfs/getFeatureById.test.ts index 0e57de3..8850b1e 100644 --- a/test/tools/wfs/getFeatureById.test.ts +++ b/test/tools/wfs/getFeatureById.test.ts @@ -1,27 +1,27 @@ import type { Collection } from "@ignfab/gpf-schema-store"; +import { jest } from "@jest/globals"; -import GpfWfsGetFeatureByIdTool from "../../../src/tools/GpfWfsGetFeatureByIdTool"; +const mockGetFeatureType = jest.fn<(typename: string) => Promise>(); +const mockFetchJSONPost = jest.fn<( + url: string, + body?: string, + headers?: Record, +) => Promise>(); -describe("Test GpfWfsGetFeatureByIdTool", () => { - class TestableGpfWfsGetFeatureByIdTool extends GpfWfsGetFeatureByIdTool { - public featureTypes: Record = {}; - public requests: Array<{ url: string; query: Record; body: string }> = []; - public nextResponse: unknown = null; - - protected async getFeatureType(typename: string) { - const featureType = this.featureTypes[typename]; - if (!featureType) { - throw new Error(`unexpected typename ${typename}`); - } - return featureType; - } +jest.unstable_mockModule("../../../src/gpf/wfs-schema-catalog.js", () => ({ + GPF_WFS_URL: "https://data.geopf.fr/wfs", + wfsClient: { + getFeatureType: mockGetFeatureType, + }, +})); - protected async fetchFeatureCollection(request: { url: string; query: Record; body: string }) { - this.requests.push(request); - return this.nextResponse; - } - } +jest.unstable_mockModule("../../../src/helpers/http.js", () => ({ + fetchJSONPost: mockFetchJSONPost, +})); + +const { default: GpfWfsGetFeatureByIdTool } = await import("../../../src/tools/GpfWfsGetFeatureByIdTool"); +describe("Test GpfWfsGetFeatureByIdTool", () => { const polygonFeatureType: Collection = { id: "ADMINEXPRESS-COG.LATEST:commune", namespace: "ADMINEXPRESS-COG.LATEST", @@ -35,6 +35,12 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { ], }; + afterEach(() => { + jest.restoreAllMocks(); + mockGetFeatureType.mockReset(); + mockFetchJSONPost.mockReset(); + }); + it("should expose an MCP definition with `results|request` result_type only", () => { const tool = new GpfWfsGetFeatureByIdTool(); expect(tool.toolDefinition.title).toEqual("Lecture d’un objet WFS par identifiant"); @@ -49,8 +55,8 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }); it("should return text content and structuredContent for request", async () => { - const tool = new TestableGpfWfsGetFeatureByIdTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + const tool = new GpfWfsGetFeatureByIdTool(); + mockGetFeatureType.mockResolvedValue(polygonFeatureType); const response = await tool.toolCall({ params: { @@ -82,9 +88,17 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }); it("should return exactly one transformed feature for results", async () => { - const tool = new TestableGpfWfsGetFeatureByIdTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { + const tool = new GpfWfsGetFeatureByIdTool(); + const requests: Array<{ url: string; query: Record; body: string }> = []; + mockGetFeatureType.mockResolvedValue(polygonFeatureType); + mockFetchJSONPost.mockImplementation(async (url, body) => { + const [baseUrl, queryString = ""] = url.split("?"); + requests.push({ + url: baseUrl, + query: Object.fromEntries(new URLSearchParams(queryString).entries()), + body, + }); + return { type: "FeatureCollection", totalFeatures: 1, features: [ @@ -99,7 +113,8 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }, }, ], - }; + }; + }); const response = await tool.toolCall({ params: { @@ -112,7 +127,7 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }); expect(response.isError).toBeUndefined(); - expect(tool.requests).toHaveLength(1); + expect(requests).toHaveLength(1); const textContent = response.content[0]; if (textContent.type !== "text") { throw new Error("expected text content"); @@ -130,9 +145,9 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }); it("should fail clearly when the feature is missing", async () => { - const tool = new TestableGpfWfsGetFeatureByIdTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { type: "FeatureCollection", features: [], totalFeatures: 0 }; + const tool = new GpfWfsGetFeatureByIdTool(); + mockGetFeatureType.mockResolvedValue(polygonFeatureType); + mockFetchJSONPost.mockResolvedValue({ type: "FeatureCollection", features: [], totalFeatures: 0 }); const response = await tool.toolCall({ params: { @@ -154,16 +169,16 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }); it("should fail clearly when multiple features are returned", async () => { - const tool = new TestableGpfWfsGetFeatureByIdTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { + const tool = new GpfWfsGetFeatureByIdTool(); + mockGetFeatureType.mockResolvedValue(polygonFeatureType); + mockFetchJSONPost.mockResolvedValue({ type: "FeatureCollection", features: [ { type: "Feature", id: "commune.1", geometry: null, properties: {} }, { type: "Feature", id: "commune.2", geometry: null, properties: {} }, ], totalFeatures: 2, - }; + }); const response = await tool.toolCall({ params: { @@ -184,15 +199,15 @@ describe("Test GpfWfsGetFeatureByIdTool", () => { }); it("should fail clearly when the returned feature id mismatches", async () => { - const tool = new TestableGpfWfsGetFeatureByIdTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { + const tool = new GpfWfsGetFeatureByIdTool(); + mockGetFeatureType.mockResolvedValue(polygonFeatureType); + mockFetchJSONPost.mockResolvedValue({ type: "FeatureCollection", features: [ { type: "Feature", id: "commune.2", geometry: null, properties: {} }, ], totalFeatures: 1, - }; + }); const response = await tool.toolCall({ params: { diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index 5e93954..e302f78 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -1,520 +1,566 @@ import type { Collection } from "@ignfab/gpf-schema-store"; - -import GpfWfsGetFeaturesTool from "../../../src/tools/GpfWfsGetFeaturesTool"; - -describe("Test GpfWfsGetFeaturesTool",() => { - class TestableGpfWfsGetFeaturesTool extends GpfWfsGetFeaturesTool { - public featureTypes: Record = {}; - public requests: Array<{ url: string; query: Record; body: string }> = []; - public nextResponse: unknown = null; - - respond(data: unknown) { - return this.createSuccessResponse(data); - } - - protected async getFeatureType(typename: string) { - const featureType = this.featureTypes[typename]; - if (!featureType) { - throw new Error(`unexpected typename ${typename}`); - } - return featureType; - } - - protected async fetchFeatureCollection(request: { url: string; query: Record; body: string }) { - this.requests.push(request); - return this.nextResponse; - } +import { jest } from "@jest/globals"; + +const mockGetFeatureType = jest.fn<(typename: string) => Promise>(); +const mockFetchJSONPost = jest.fn<( + url: string, + body?: string, + headers?: Record, +) => Promise>(); + +jest.unstable_mockModule("../../../src/gpf/wfs-schema-catalog.js", () => ({ + GPF_WFS_URL: "https://data.geopf.fr/wfs", + wfsClient: { + getFeatureType: mockGetFeatureType, + }, +})); + +jest.unstable_mockModule("../../../src/helpers/http.js", () => ({ + fetchJSONPost: mockFetchJSONPost, +})); + +const { default: GpfWfsGetFeaturesTool } = await import( + "../../../src/tools/GpfWfsGetFeaturesTool" +); + +describe("Test GpfWfsGetFeaturesTool", () => { + class RespondableGpfWfsGetFeaturesTool extends GpfWfsGetFeaturesTool { + respond(data: unknown) { + return this.createSuccessResponse(data); } + } + + const polygonFeatureType: Collection = { + id: "ADMINEXPRESS-COG.LATEST:commune", + namespace: "ADMINEXPRESS-COG.LATEST", + name: "commune", + title: "Commune", + description: "Description de test", + properties: [ + { name: "code_insee", type: "string" }, + { name: "population", type: "integer" }, + { name: "actif", type: "boolean" }, + { name: "geometrie", type: "multipolygon", defaultCrs: "EPSG:4326" }, + ], + }; + + const pointFeatureType: Collection = { + id: "BDTOPO_V3:point_d_acces", + namespace: "BDTOPO_V3", + name: "point_d_acces", + title: "Point d'acces", + description: "Description de test", + properties: [ + { name: "cleabs", type: "string" }, + { name: "geometrie", type: "point", defaultCrs: "EPSG:4326" }, + ], + }; + + const multipointFeatureType: Collection = { + id: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + namespace: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS", + name: "localisant", + title: "Localisant", + description: "Description de test", + properties: [ + { name: "gid", type: "integer" }, + { name: "idu", type: "string" }, + { name: "geometrie", type: "multipoint", defaultCrs: "EPSG:4326" }, + ], + }; + + const featureCollection: { + type: string; + features: Array<{ + type: string; + id: string; + geometry: null; + properties: { + code_insee: string; + }; + }>; + totalFeatures: number; + } = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "commune.1", + geometry: null, + properties: { + code_insee: "01001", + }, + }, + ], + totalFeatures: 34877, + }; + + function mockFeatureTypes(featureTypes: Record) { + mockGetFeatureType.mockImplementation(async (typename: string) => { + const featureType = featureTypes[typename]; + if (!featureType) { + throw new Error(`unexpected typename ${typename}`); + } + return featureType; + }); + } + + function captureRequests(responseData: unknown) { + const requests: Array<{ + url: string; + query: Record; + body: string; + }> = []; + + mockFetchJSONPost.mockImplementation(async (url, body) => { + const [baseUrl, queryString = ""] = url.split("?"); + requests.push({ + url: baseUrl, + query: Object.fromEntries(new URLSearchParams(queryString).entries()), + body: body ?? "", + }); + return responseData; + }); + + return requests; + } + + afterEach(() => { + jest.restoreAllMocks(); + mockGetFeatureType.mockReset(); + mockFetchJSONPost.mockReset(); + }); + + it("should expose an enriched MCP definition", () => { + const tool = new GpfWfsGetFeaturesTool(); + expect(tool.toolDefinition.title).toEqual("Lecture d’objets WFS"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.inputSchema.properties?.limit).toMatchObject({ + type: "integer", + minimum: 1, + maximum: 5000, + }); + expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ + type: "array", + }); + expect(tool.toolDefinition.inputSchema.properties?.order_by).toMatchObject({ + type: "array", + }); + expect(tool.toolDefinition.inputSchema.properties?.where).toMatchObject({ + type: "array", + }); + expect(tool.toolDefinition.outputSchema).toBeUndefined(); + }); + + it("should return a FeatureCollection without structuredContent for results", () => { + const tool = new RespondableGpfWfsGetFeaturesTool(); + const response = tool.respond(featureCollection as never); - const polygonFeatureType: Collection = { - id: "ADMINEXPRESS-COG.LATEST:commune", - namespace: "ADMINEXPRESS-COG.LATEST", - name: "commune", - title: "Commune", - description: "Description de test", - properties: [ - { name: "code_insee", type: "string" }, - { name: "population", type: "integer" }, - { name: "actif", type: "boolean" }, - { name: "geometrie", type: "multipolygon", defaultCrs: "EPSG:4326" }, - ], - }; - - const pointFeatureType: Collection = { - id: "BDTOPO_V3:point_d_acces", - namespace: "BDTOPO_V3", - name: "point_d_acces", - title: "Point d'acces", - description: "Description de test", - properties: [ - { name: "cleabs", type: "string" }, - { name: "geometrie", type: "point", defaultCrs: "EPSG:4326" }, - ], - }; - - const multipointFeatureType: Collection = { - id: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - namespace: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS", - name: "localisant", - title: "Localisant", - description: "Description de test", - properties: [ - { name: "gid", type: "integer" }, - { name: "idu", type: "string" }, - { name: "geometrie", type: "multipoint", defaultCrs: "EPSG:4326" }, - ], - }; - - const featureCollection: { - type: string; - features: Array<{ - type: string; - id: string; - geometry: null; - properties: { - code_insee: string; - }; - }>; - totalFeatures: number; - } = { - type: "FeatureCollection", - features: [ + expect("isError" in response).toBe(false); + expect(response.structuredContent).toBeUndefined(); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toMatchObject({ + type: "FeatureCollection", + features: expect.any(Array), + }); + }); + + it("should return text content and structuredContent for hits", () => { + const tool = new RespondableGpfWfsGetFeaturesTool(); + const response = tool.respond({ + result_type: "hits", + totalFeatures: featureCollection.totalFeatures, + } as never); + + expect("isError" in response).toBe(false); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(Number(JSON.parse(textContent.text))).toBeGreaterThan(0); + expect(response.structuredContent).toMatchObject({ + result_type: "hits", + totalFeatures: expect.any(Number), + }); + }); + + it("should return text content and structuredContent for request", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "request", + select: ["code_insee"], + where: [ { - type: "Feature", - id: "commune.1", - geometry: null, - properties: { - code_insee: "01001", - }, - }, - ], - totalFeatures: 34877, - }; - - it("should expose an enriched MCP definition", () => { - const tool = new GpfWfsGetFeaturesTool(); - expect(tool.toolDefinition.title).toEqual("Lecture d’objets WFS"); - expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ - type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.inputSchema.properties?.limit).toMatchObject({ - type: "integer", - minimum: 1, - maximum: 5000, - }); - expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ - type: "array", - }); - expect(tool.toolDefinition.inputSchema.properties?.order_by).toMatchObject({ - type: "array", - }); - expect(tool.toolDefinition.inputSchema.properties?.where).toMatchObject({ - type: "array", - }); - expect(tool.toolDefinition.outputSchema).toBeUndefined(); - }); - - it("should return a FeatureCollection without structuredContent for results", () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - const response = tool.respond(featureCollection); - - expect("isError" in response).toBe(false); - expect(response.structuredContent).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(JSON.parse(textContent.text)).toMatchObject({ - type: "FeatureCollection", - features: expect.any(Array), - }); - }); - - it("should return text content and structuredContent for hits", () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - const response = tool.respond({ - result_type: "hits", - totalFeatures: featureCollection.totalFeatures, - }); - - expect("isError" in response).toBe(false); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(Number(JSON.parse(textContent.text))).toBeGreaterThan(0); - expect(response.structuredContent).toMatchObject({ - result_type: "hits", - totalFeatures: expect.any(Number), - }); - }); - - it("should return text content and structuredContent for request", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "request", - select: ["code_insee"], - where: [ - { - property: "code_insee", - operator: "eq", - value: "01001", - }, - ], - }, - }, - }); - - 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 request = JSON.parse(textContent.text); - expect(request.method).toEqual("POST"); - expect(request.url).toContain("https://data.geopf.fr/wfs"); - expect(request.query.service).toEqual("WFS"); - expect(request.query.propertyName).toEqual("code_insee,geometrie"); - expect(request.body).toContain("cql_filter="); - expect(response.structuredContent).toMatchObject({ - result_type: "request", - method: "POST", - }); - }); - - it("should return isError=true for invalid input", async () => { - const tool = new GpfWfsGetFeaturesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("le nom du type ne doit pas être vide"); - }); - - it("should reject legacy inputs removed from the public schema", async () => { - const tool = new GpfWfsGetFeaturesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - cql_filter: "code_insee = '01001'", - }, + property: "code_insee", + operator: "eq", + value: "01001", }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toMatch(/unrecognized/i); - expect(textContent.text).toContain("cql_filter"); - }); - - it("should build a POST request with query params and encoded body", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = featureCollection; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - limit: 7, - select: ["code_insee", "population"], - order_by: [{ property: "population", direction: "desc" }], - where: [{ property: "code_insee", operator: "eq", value: "01001" }], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(tool.requests).toHaveLength(1); - expect(tool.requests[0].query.count).toEqual("7"); - expect(tool.requests[0].query.propertyName).toEqual("code_insee,population"); - expect(tool.requests[0].query.sortBy).toEqual("population D"); - expect(tool.requests[0].body).toContain("cql_filter="); - }); - - it("should keep hits independent from limit and omit propertyName", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { numberMatched: 321, totalFeatures: 999 }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "hits", - limit: 999, - select: ["code_insee"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(tool.requests[0].query.count).toEqual("1"); - expect(tool.requests[0].query.propertyName).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(JSON.parse(textContent.text)).toEqual(321); - }); - - it("should fall back to totalFeatures when numberMatched is absent", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { totalFeatures: 321 }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "hits", - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(JSON.parse(textContent.text)).toEqual(321); - }); - - it("should fail clearly when numberMatched is unknown", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { numberMatched: "unknown" }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "hits", - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("numberMatched=\"unknown\""); - }); - - it("should return feature_ref for non point layers with geometry set to null", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.nextResponse = { - ...featureCollection, - crs: null, - features: [ - { - type: "Feature", - id: "commune.1", - geometry: { type: "MultiPolygon", coordinates: [] }, - geometry_name: "geometrie", - properties: { code_insee: "01001" }, - }, - ], - }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const results = JSON.parse(textContent.text); - expect(results).not.toHaveProperty("crs"); - expect(results.features[0].geometry).toBeNull(); - expect(results.features[0].feature_ref).toEqual({ - typename: "ADMINEXPRESS-COG.LATEST:commune", - feature_id: "commune.1", - }); - expect(results.features[0].geometry_name).toBeUndefined(); - }); - - it("should set point geometry to null and keep feature_ref", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[pointFeatureType.id] = pointFeatureType; - tool.nextResponse = { - type: "FeatureCollection", - features: [ - { - type: "Feature", - id: "point_d_acces.1", - geometry: { type: "Point", coordinates: [2.3, 48.8] }, - geometry_name: "geometrie", - properties: { cleabs: "id-1" }, - }, - ], - totalFeatures: 1, - }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "BDTOPO_V3:point_d_acces", - select: ["cleabs"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(tool.requests[0].query.propertyName).toEqual("cleabs"); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const results = JSON.parse(textContent.text); - expect(results.features[0].geometry).toBeNull(); - expect(results.features[0].feature_ref).toEqual({ - typename: "BDTOPO_V3:point_d_acces", - feature_id: "point_d_acces.1", - }); - expect(results.features[0].geometry_name).toBeUndefined(); - }); - - it("should resolve intersects_feature from MultiPoint references", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.featureTypes[multipointFeatureType.id] = multipointFeatureType; - tool.nextResponse = { - type: "FeatureCollection", - features: [ - { - type: "Feature", - id: "localisant.1", - geometry: { type: "MultiPoint", coordinates: [[2.3, 48.8], [2.4, 48.9]] }, - properties: {}, - }, - ], - totalFeatures: 1, - }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - spatial_operator: "intersects_feature", - intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - intersects_feature_id: "localisant.1", - result_type: "request", - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(tool.requests).toHaveLength(1); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const request = JSON.parse(textContent.text); - expect(request.body).toContain("MULTIPOINT"); - }); - - it("should report missing reference features clearly for intersects_feature", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; - tool.featureTypes[multipointFeatureType.id] = multipointFeatureType; - tool.nextResponse = { - type: "FeatureCollection", - features: [], - totalFeatures: 0, - }; - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - spatial_operator: "intersects_feature", - intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - intersects_feature_id: "localisant.404", - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("est introuvable"); - expect(textContent.text).toContain("localisant.404"); - }); - - it("should reject intersects_feature on the same typename and guide to by-id tool", async () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - spatial_operator: "intersects_feature", - intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_id: "commune.1", - }, - }, - }); + ], + }, + }, + }); + + 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 request = JSON.parse(textContent.text); + expect(request.method).toEqual("POST"); + expect(request.url).toContain("https://data.geopf.fr/wfs"); + expect(request.query.service).toEqual("WFS"); + expect(request.query.propertyName).toEqual("code_insee,geometrie"); + expect(request.body).toContain("cql_filter="); + expect(response.structuredContent).toMatchObject({ + result_type: "request", + method: "POST", + }); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfWfsGetFeaturesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("le nom du type ne doit pas être vide"); + }); + + it("should reject legacy inputs removed from the public schema", async () => { + const tool = new GpfWfsGetFeaturesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + cql_filter: "code_insee = '01001'", + }, + }, + }); - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("gpf_wfs_get_feature_by_id"); - expect(textContent.text).toContain("intersects_feature"); - expect(tool.requests).toHaveLength(0); + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toMatch(/unrecognized/i); + expect(textContent.text).toContain("cql_filter"); + }); + + it("should build a POST request with query params and encoded body", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + const requests = captureRequests(featureCollection); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + limit: 7, + select: ["code_insee", "population"], + order_by: [{ property: "population", direction: "desc" }], + where: [{ property: "code_insee", operator: "eq", value: "01001" }], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(requests).toHaveLength(1); + expect(requests[0].query.count).toEqual("7"); + expect(requests[0].query.propertyName).toEqual("code_insee,population"); + expect(requests[0].query.sortBy).toEqual("population D"); + expect(requests[0].body).toContain("cql_filter="); + }); + + it("should keep hits independent from limit and omit propertyName", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + const requests = captureRequests({ numberMatched: 321, totalFeatures: 999 }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "hits", + limit: 999, + select: ["code_insee"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(requests[0].query.count).toEqual("1"); + expect(requests[0].query.propertyName).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toEqual(321); + }); + + it("should fall back to totalFeatures when numberMatched is absent", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + captureRequests({ totalFeatures: 321 }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "hits", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toEqual(321); + }); + + it("should fail clearly when numberMatched is unknown", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + captureRequests({ numberMatched: "unknown" }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "hits", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain('numberMatched="unknown"'); + }); + + it("should return feature_ref for non point layers with geometry set to null", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + captureRequests({ + ...featureCollection, + crs: null, + features: [ + { + type: "Feature", + id: "commune.1", + geometry: { type: "MultiPolygon", coordinates: [] }, + geometry_name: "geometrie", + properties: { code_insee: "01001" }, + }, + ], + }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const results = JSON.parse(textContent.text); + expect(results).not.toHaveProperty("crs"); + expect(results.features[0].geometry).toBeNull(); + expect(results.features[0].feature_ref).toEqual({ + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }); + expect(results.features[0].geometry_name).toBeUndefined(); + }); + + it("should set point geometry to null and keep feature_ref", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ [pointFeatureType.id]: pointFeatureType }); + const requests = captureRequests({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "point_d_acces.1", + geometry: { type: "Point", coordinates: [2.3, 48.8] }, + geometry_name: "geometrie", + properties: { cleabs: "id-1" }, + }, + ], + totalFeatures: 1, + }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "BDTOPO_V3:point_d_acces", + select: ["cleabs"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(requests[0].query.propertyName).toEqual("cleabs"); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const results = JSON.parse(textContent.text); + expect(results.features[0].geometry).toBeNull(); + expect(results.features[0].feature_ref).toEqual({ + typename: "BDTOPO_V3:point_d_acces", + feature_id: "point_d_acces.1", + }); + expect(results.features[0].geometry_name).toBeUndefined(); + }); + + it("should resolve intersects_feature from MultiPoint references", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ + [polygonFeatureType.id]: polygonFeatureType, + [multipointFeatureType.id]: multipointFeatureType, }); + const requests = captureRequests({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "localisant.1", + geometry: { type: "MultiPoint", coordinates: [[2.3, 48.8], [2.4, 48.9]] }, + properties: {}, + }, + ], + totalFeatures: 1, + }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + spatial_operator: "intersects_feature", + intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + intersects_feature_id: "localisant.1", + result_type: "request", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(requests).toHaveLength(1); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const request = JSON.parse(textContent.text); + expect(request.body).toContain("MULTIPOINT"); + }); + + it("should report missing reference features clearly for intersects_feature", async () => { + const tool = new GpfWfsGetFeaturesTool(); + mockFeatureTypes({ + [polygonFeatureType.id]: polygonFeatureType, + [multipointFeatureType.id]: multipointFeatureType, + }); + captureRequests({ + type: "FeatureCollection", + features: [], + totalFeatures: 0, + }); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + spatial_operator: "intersects_feature", + intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + intersects_feature_id: "localisant.404", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("est introuvable"); + expect(textContent.text).toContain("localisant.404"); + }); + + it("should reject intersects_feature on the same typename and guide to by-id tool", async () => { + const tool = new GpfWfsGetFeaturesTool(); + const requests = captureRequests(featureCollection); + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + spatial_operator: "intersects_feature", + intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", + intersects_feature_id: "commune.1", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("gpf_wfs_get_feature_by_id"); + expect(textContent.text).toContain("intersects_feature"); + expect(requests).toHaveLength(0); + }); });