From 9c3da8e3ff491d4acc74fd6dbc9fee1340a5f260 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 3 Jul 2026 16:30:28 +0200 Subject: [PATCH 1/2] feat: Remove result_type to make GpfGetFeatures(ById) return structured content --- docs/mcp-tools.md | 263 +++++++++++++++++++++---- scripts/generate-mcp-docs.mjs | 64 +----- src/tools/GpfGetFeatureByIdTool.ts | 61 +----- src/tools/GpfGetFeaturesTool.ts | 45 +---- src/wfs/byId.ts | 10 +- src/wfs/features.ts | 23 +-- src/wfs/properties.ts | 54 ++--- src/wfs/queryPreparation.ts | 9 +- src/wfs/request.ts | 62 +----- src/wfs/schema.ts | 90 ++++----- test/scripts/generate-mcp-docs.test.ts | 4 - test/tools/wfs/getFeatureById.test.ts | 97 +-------- test/tools/wfs/getFeatures.test.ts | 112 +---------- test/wfs/queryPreparation.test.ts | 11 -- test/wfs/spatialFilter.test.ts | 1 - 15 files changed, 325 insertions(+), 581 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 1feed22b..9b2e53a8 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1092,7 +1092,7 @@ Lecture d’un objet GPF par identifiant Récupère exactement un objet GPF à partir de `typename` et `feature_id`, sans filtre attributaire ni spatial. Ce tool est le chemin robuste quand vous disposez déjà d'une `feature_ref { typename, feature_id }` issue d'un autre tool (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`, `gpf_get_features`). Le contrat garantit une cardinalité stricte : 0 résultat ou plusieurs résultats provoquent une erreur explicite. -Utiliser `result_type="http_post_request"` pour récupérer une requête POST robuste, ou `result_type="http_get_url"` pour récupérer l'URL GET équivalente et l'utiliser ou la visualiser dans un outil la supportant. +Pour télécharger le fichier GeoJSON contenant la géométrie de l'objet, utilisez le lien renvoyé dans le champ `collection_url` ``` ### Schéma d’entrée @@ -1100,8 +1100,7 @@ Utiliser `result_type="http_post_request"` pour récupérer une requête POST ro | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `feature_id` | string | oui | Identifiant GPF exact de l'objet à récupérer, par exemple `commune.8952`. | -| `result_type` | string (enum) | non | `results` renvoie une FeatureCollection normalisée avec exactement un objet et le choix de `spatial_extras` en guise d'information géométrique. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant. Valeurs : results, http_post_request, http_get_url. Valeur par défaut : results. | -| `select` | array | non | Liste des propriétés non géométriques à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. | +| `select` | array | non | Liste des attributs (à l'exception de la géométrie) à renvoyer. Utiliser `gpf_wfs_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 `result_type=results`. Peut inclure `centroid` et `bbox`, aucun par défaut. Valeur par défaut : []. | | `typename` | string | oui | Nom exact du type GPF à interroger, par exemple `ADMINEXPRESS-COG.LATEST:commune`. | @@ -1122,16 +1121,6 @@ Utiliser `result_type="http_post_request"` pour récupérer une requête POST ro "minLength": 1, "description": "Identifiant GPF exact de l'objet à récupérer, par exemple `commune.8952`." }, - "result_type": { - "type": "string", - "enum": [ - "results", - "http_post_request", - "http_get_url" - ], - "default": "results", - "description": "`results` renvoie une FeatureCollection normalisée avec exactement un objet et le choix de `spatial_extras` en guise d'information géométrique. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant." - }, "select": { "type": "array", "items": { @@ -1139,7 +1128,7 @@ Utiliser `result_type="http_post_request"` pour récupérer une requête POST ro "minLength": 1 }, "minItems": 1, - "description": "Liste des propriétés non géométriques à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`." + "description": "Liste des attributs (à l'exception de la géométrie) à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`." }, "spatial_extras": { "type": "array", @@ -1165,17 +1154,121 @@ Utiliser `result_type="http_post_request"` pour récupérer une requête POST ro -### Sortie +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `collection_url` | string | oui | Le lien vers le GeoJSON qui inclut les géométries. | +| `features` | array | oui | La liste avec l'objet exact de la requête. | +| `numberMatched` | number | oui | Le nombre de résultats correspondant à la requête sur le serveur. | +| `numberReturned` | number | oui | Le nombre de résultats renvoyés, au maximum `limit` | +| `timeStamp` | string | oui | La date et l'heure de la requête. | +| `totalFeatures` | number | oui | Le nombre de résultats correspondant à la requête sur le serveur. | +| `type` | string | oui | Le type GeoJSON. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Le type GeoJSON." + }, + "totalFeatures": { + "type": "number", + "description": "Le nombre de résultats correspondant à la requête sur le serveur." + }, + "numberMatched": { + "type": "number", + "description": "Le nombre de résultats correspondant à la requête sur le serveur." + }, + "numberReturned": { + "type": "number", + "description": "Le nombre de résultats renvoyés, au maximum `limit`" + }, + "timeStamp": { + "type": "string", + "description": "La date et l'heure de la requête." + }, + "collection_url": { + "type": "string", + "description": "Le lien vers le GeoJSON qui inclut les géométries.", + "format": "uri" + }, + "features": { + "type": "array", + "description": "La liste avec l'objet exact de la requête.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Le type GeoJSON." + }, + "id": { + "type": "string", + "description": "L'identifiant de l'objet." + }, + "feature_ref": { + "type": "object", + "description": "Référence GPF réutilisable, notamment avec `gpf_get_features` et `intersects_feature_filter`.", + "properties": { + "typename": { + "type": "string", + "description": "Le `typename` GPF réutilisable pour une requête ultérieure." + }, + "feature_id": { + "type": "string", + "description": "L'identifiant GPF réutilisable du feature." + } + }, + "required": [ + "typename", + "feature_id" + ] + }, + "properties": { + "type": "object", + "description": "Les attributs de l'objet.", + "properties": {} + }, + "geometry": { + "type": "null", + "description": "La géométrie de l'objet, non incluse. Celle-ci est accessible au lien `collection_url`." + } + }, + "required": [ + "type", + "id", + "feature_ref", + "properties", + "geometry" + ] + } + } + }, + "required": [ + "type", + "totalFeatures", + "numberMatched", + "numberReturned", + "timeStamp", + "collection_url", + "features" + ] +} +``` -Aucun `outputSchema` unique n'est exposé. La sortie dépend de `result_type` (`results`, `http_post_request`, `http_get_url`). +
### Réponse MCP | Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` | | --- | --- | --- | --- | -| Succès `result_type="results"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | -| Succès `result_type="http_post_request"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | -| Succès `result_type="http_get_url"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | +| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. | ## `gpf_get_features` @@ -1190,7 +1283,7 @@ Lecture d’objets GPF ``` Interroge un type GPF et renvoie des résultats structurés. -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. Avec `result_type="http_post_request"` ou `result_type="http_get_url"`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable. +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. 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 }`. @@ -1200,6 +1293,7 @@ Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: ⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé. **OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_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_describe_type` **provoquera une erreur.** +Pour télécharger le fichier GeoJSON contenant la géométrie des objets, utilisez le lien renvoyé dans le champ `collection_url` ``` ### Schéma d’entrée @@ -1212,8 +1306,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | | `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. | -| `result_type` | string (enum) | non | `results` renvoie une FeatureCollection avec les propriétés attributaires uniquement — **les géométries ne sont pas incluses**, ce mode ne peut donc pas être utilisé directement pour cartographier. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant. Avec `http_post_request` ou `http_get_url`, la géométrie est automatiquement ajoutée aux propriétés du `select` pour garantir l'affichage cartographique. Valeurs : results, http_post_request, http_get_url. Valeur par défaut : results. | -| `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"]`. | +| `select` | array | non | Liste des attributs (à l'exception de la géométrie) à 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 `result_type=results`. Peut inclure `centroid` et `bbox`, aucun par défaut. 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, par exemple `BDTOPO_V3:batiment`. Utiliser `gpf_search_types` pour trouver un `typename` valide. | @@ -1238,7 +1331,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "minLength": 1 }, "minItems": 1, - "description": "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\"]`." + "description": "Liste des attributs (à l'exception de la géométrie) à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`." }, "where": { "type": "array", @@ -1443,16 +1536,6 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "default": 100, "description": "Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000." }, - "result_type": { - "type": "string", - "enum": [ - "results", - "http_post_request", - "http_get_url" - ], - "default": "results", - "description": "`results` renvoie une FeatureCollection avec les propriétés attributaires uniquement — **les géométries ne sont pas incluses**, ce mode ne peut donc pas être utilisé directement pour cartographier. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant. Avec `http_post_request` ou `http_get_url`, la géométrie est automatiquement ajoutée aux propriétés du `select` pour garantir l'affichage cartographique." - }, "order_by": { "type": "array", "items": { @@ -1505,17 +1588,121 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu -### Sortie +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `collection_url` | string | oui | Le lien vers le GeoJSON qui inclut les géométries. | +| `features` | array | oui | La liste des objets correspondant à la requête. | +| `numberMatched` | number | oui | Le nombre de résultats correspondant à la requête sur le serveur. | +| `numberReturned` | number | oui | Le nombre de résultats renvoyés, au maximum `limit` | +| `timeStamp` | string | oui | La date et l'heure de la requête. | +| `totalFeatures` | number | oui | Le nombre de résultats correspondant à la requête sur le serveur. | +| `type` | string | oui | Le type GeoJSON. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Le type GeoJSON." + }, + "totalFeatures": { + "type": "number", + "description": "Le nombre de résultats correspondant à la requête sur le serveur." + }, + "numberMatched": { + "type": "number", + "description": "Le nombre de résultats correspondant à la requête sur le serveur." + }, + "numberReturned": { + "type": "number", + "description": "Le nombre de résultats renvoyés, au maximum `limit`" + }, + "timeStamp": { + "type": "string", + "description": "La date et l'heure de la requête." + }, + "collection_url": { + "type": "string", + "description": "Le lien vers le GeoJSON qui inclut les géométries.", + "format": "uri" + }, + "features": { + "type": "array", + "description": "La liste des objets correspondant à la requête.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Le type GeoJSON." + }, + "id": { + "type": "string", + "description": "L'identifiant de l'objet." + }, + "feature_ref": { + "type": "object", + "description": "Référence GPF réutilisable, notamment avec `gpf_get_features` et `intersects_feature_filter`.", + "properties": { + "typename": { + "type": "string", + "description": "Le `typename` GPF réutilisable pour une requête ultérieure." + }, + "feature_id": { + "type": "string", + "description": "L'identifiant GPF réutilisable du feature." + } + }, + "required": [ + "typename", + "feature_id" + ] + }, + "properties": { + "type": "object", + "description": "Les attributs de l'objet.", + "properties": {} + }, + "geometry": { + "type": "null", + "description": "La géométrie de l'objet, non incluse. Celle-ci est accessible au lien `collection_url`." + } + }, + "required": [ + "type", + "id", + "feature_ref", + "properties", + "geometry" + ] + } + } + }, + "required": [ + "type", + "totalFeatures", + "numberMatched", + "numberReturned", + "timeStamp", + "collection_url", + "features" + ] +} +``` -Aucun `outputSchema` unique n'est exposé. La sortie dépend de `result_type` (`results`, `http_post_request`, `http_get_url`). +
### Réponse MCP | Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` | | --- | --- | --- | --- | -| Succès `result_type="results"` | oui | non | `content[0].text` est la FeatureCollection stringifiée ; aucun `structuredContent` n'est ajouté dans ce mode. | -| Succès `result_type="http_post_request"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | -| Succès `result_type="http_get_url"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | +| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. | | Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. | ## `gpf_count_features` diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs index bb4bb310..2deaf3c0 100644 --- a/scripts/generate-mcp-docs.mjs +++ b/scripts/generate-mcp-docs.mjs @@ -193,62 +193,6 @@ export function renderResponseContractSection(definition) { relation: "`content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`.", }; - if (definition.name === "gpf_get_features") { - return [ - "### Réponse MCP", - "", - renderResponseContractTable([ - { - caseName: 'Succès `result_type="results"`', - content: "oui", - structuredContent: "non", - relation: "`content[0].text` est la FeatureCollection stringifiée ; aucun `structuredContent` n'est ajouté dans ce mode.", - }, - { - caseName: 'Succès `result_type="http_post_request"`', - content: "oui", - structuredContent: "oui", - relation: "`content[0].text` est `JSON.stringify(structuredContent)`.", - }, - { - caseName: 'Succès `result_type="http_get_url"`', - content: "oui", - structuredContent: "oui", - relation: "`content[0].text` est `JSON.stringify(structuredContent)`.", - }, - errorRow, - ]), - ].join("\n"); - } - - if (definition.name === "gpf_get_feature_by_id") { - return [ - "### Réponse MCP", - "", - renderResponseContractTable([ - { - caseName: 'Succès `result_type="results"`', - content: "oui", - structuredContent: "oui", - relation: "`content[0].text` est `JSON.stringify(structuredContent)`.", - }, - { - caseName: 'Succès `result_type="http_post_request"`', - content: "oui", - structuredContent: "oui", - relation: "`content[0].text` est `JSON.stringify(structuredContent)`.", - }, - { - caseName: 'Succès `result_type="http_get_url"`', - content: "oui", - structuredContent: "oui", - relation: "`content[0].text` est `JSON.stringify(structuredContent)`.", - }, - errorRow, - ]), - ].join("\n"); - } - return [ "### Réponse MCP", "", @@ -286,13 +230,7 @@ export function renderOutputSection(definition) { ].join("\n"); } - const modes = - definition.inputSchema?.properties?.result_type?.enum?.map((value) => `\`${String(value)}\``) ?? - []; - - const note = modes.length - ? `Aucun \`outputSchema\` unique n'est exposé. La sortie dépend de \`result_type\` (${modes.join(", ")}).` - : "Aucun `outputSchema` unique n'est exposé. La sortie est gérée par la sérialisation par défaut du framework ou par un formatage de réponse spécifique."; + const note = "Aucun `outputSchema` unique n'est exposé. La sortie est gérée par la sérialisation par défaut du framework ou par un formatage de réponse spécifique."; return ["### Sortie", "", note].join("\n"); } diff --git a/src/tools/GpfGetFeatureByIdTool.ts b/src/tools/GpfGetFeatureByIdTool.ts index f6b62e81..c64f2015 100644 --- a/src/tools/GpfGetFeatureByIdTool.ts +++ b/src/tools/GpfGetFeatureByIdTool.ts @@ -9,20 +9,13 @@ import BaseTool from "./BaseTool.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { buildPropertyName, executeGetFeatureById } from "../wfs/byId.js"; -import { wfsClient } from "../wfs/execution.js"; +import { executeGetFeatureById } from "../wfs/byId.js"; import { - buildGetFeatureByIdRequest, - toWfsHttpGetUrlPayload, - toWfsHttpPostRequestPayload, -} from "../wfs/request.js"; -import { - gpfGetFeatureByIdHttpGetUrlOutputSchema, - gpfGetFeatureByIdHttpPostRequestOutputSchema, gpfGetFeatureByIdInputObjectSchema, gpfGetFeatureByIdInputSchema, type GpfGetFeatureByIdInput, gpfGetFeatureByIdPublishedInputSchema, + getFeatureByIdOutputSchema, } from "../wfs/schema.js"; import logger from "../logger.js"; @@ -36,8 +29,9 @@ class GpfGetFeatureByIdTool extends BaseTool { "Récupère exactement un objet GPF à partir de `typename` et `feature_id`, sans filtre attributaire ni spatial.", "Ce tool est le chemin robuste quand vous disposez déjà d'une `feature_ref { typename, feature_id }` issue d'un autre tool (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`, `gpf_get_features`).", "Le contrat garantit une cardinalité stricte : 0 résultat ou plusieurs résultats provoquent une erreur explicite.", - "Utiliser `result_type=\"http_post_request\"` pour récupérer une requête POST robuste, ou `result_type=\"http_get_url\"` pour récupérer l'URL GET équivalente et l'utiliser ou la visualiser dans un outil la supportant." + "Pour télécharger le fichier GeoJSON contenant la géométrie de l'objet, utilisez le lien renvoyé dans le champ `collection_url`", ].join("\n"); + protected outputSchemaShape = getFeatureByIdOutputSchema; // `schema` remains the runtime validation source, while `inputSchema` // publishes the MCP-facing variant expected by clients. @@ -55,7 +49,7 @@ class GpfGetFeatureByIdTool extends BaseTool { } /** - * Formats compact responses (`http_post_request`, `http_get_url`, `results`) into `structuredContent`. + * Formats compact responses into `structuredContent`. * * We intentionally do not expose a single `outputSchemaShape` for the tool as * a whole: the `results` path returns a generic FeatureCollection whose @@ -66,34 +60,6 @@ class GpfGetFeatureByIdTool extends BaseTool { * @returns An MCP success response, optionally enriched with structured content. */ protected createSuccessResponse(data: unknown) { - if ( - typeof data === "object" && - data !== null && - "result_type" in data && - data.result_type === "http_post_request" - ) { - const payload = gpfGetFeatureByIdHttpPostRequestOutputSchema.parse(data); - - return { - content: [{ type: "text" as const, text: JSON.stringify(payload) }], - structuredContent: payload, - }; - } - - if ( - typeof data === "object" && - data !== null && - "result_type" in data && - data.result_type === "http_get_url" - ) { - const payload = gpfGetFeatureByIdHttpGetUrlOutputSchema.parse(data); - - return { - content: [{ type: "text" as const, text: JSON.stringify(payload) }], - structuredContent: payload, - }; - } - if ( typeof data === "object" && data !== null && @@ -107,7 +73,7 @@ class GpfGetFeatureByIdTool extends BaseTool { } throw new Error( - "Réponse interne inattendue pour gpf_get_feature_by_id : le résultat devrait être une requête HTTP, une URL GET ou une FeatureCollection.", + "Réponse interne inattendue pour gpf_get_feature_by_id : le résultat devrait être une FeatureCollection.", ); } @@ -123,21 +89,6 @@ class GpfGetFeatureByIdTool extends BaseTool { logger.info(`[tool] execute ${this.name} ...`, { input: validatedInput }); - - if (validatedInput.result_type === "http_post_request" || validatedInput.result_type === "http_get_url") { - // HTTP preview modes are handled here because they return a preview payload, - // not the actual by-id WFS result. - const featureType = await wfsClient.getFeatureType(validatedInput.typename); - const propertyName = buildPropertyName(featureType, { - includeGeometry: true, - select: validatedInput.select, - }); - const request = buildGetFeatureByIdRequest(validatedInput.typename, validatedInput.feature_id, propertyName); - return validatedInput.result_type === "http_post_request" - ? toWfsHttpPostRequestPayload(request) - : toWfsHttpGetUrlPayload(request); - } - return executeGetFeatureById({ typename: input.typename, feature_id: input.feature_id, diff --git a/src/tools/GpfGetFeaturesTool.ts b/src/tools/GpfGetFeaturesTool.ts index ad8227e3..5fa8c3bc 100644 --- a/src/tools/GpfGetFeaturesTool.ts +++ b/src/tools/GpfGetFeaturesTool.ts @@ -3,20 +3,14 @@ import BaseTool from "./BaseTool.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import { executeQueryFeatures, - prepareQueryFeaturesRequest, } from "../wfs/features.js"; import { - toWfsHttpGetUrlPayload, - toWfsHttpPostRequestPayload, -} from "../wfs/request.js"; -import { - gpfGetFeaturesHttpGetUrlOutputSchema, - gpfGetFeaturesHttpPostRequestOutputSchema, gpfGetFeaturesInputSchema, gpfGetFeaturesInputObjectSchema, type GpfGetFeaturesInput, gpfGetFeaturesPublishedInputSchema, GPF_SPATIAL_FILTER_DOCNAMES, + getFeaturesOutputSchema, } from "../wfs/schema.js"; import logger from "../logger.js"; @@ -35,7 +29,7 @@ class GpfGetFeaturesTool extends BaseTool { annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; description = [ "Interroge un type GPF et renvoie des résultats structurés.", - `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. Avec \`result_type="http_post_request"\` ou \`result_type="http_get_url"\`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable.`, + `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.`, "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 }`.", @@ -45,7 +39,9 @@ class GpfGetFeaturesTool extends BaseTool { "⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.", "**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_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_describe_type` **provoquera une erreur.**", + "Pour télécharger le fichier GeoJSON contenant la géométrie des objets, utilisez le lien renvoyé dans le champ `collection_url`", ].join("\n"); + protected outputSchemaShape = getFeaturesOutputSchema; // The framework requires a plain Zod object here to publish a compatible // input schema. Cross-field runtime validation is applied in `execute`. @@ -61,7 +57,7 @@ class GpfGetFeaturesTool extends BaseTool { } /** - * Formats compact responses (`http_post_request`, `http_get_url`) into `structuredContent`. + * Formats compact responses into `structuredContent`. * Full result sets are still delegated to the framework default behavior. * * @param data Raw execution result returned by the tool implementation. @@ -71,28 +67,12 @@ class GpfGetFeaturesTool extends BaseTool { if ( typeof data === "object" && data !== null && - "result_type" in data && - data.result_type === "http_post_request" + "type" in data && + data.type === "FeatureCollection" ) { - const payload = gpfGetFeaturesHttpPostRequestOutputSchema.parse(data); - return { - content: [{ type: "text" as const, text: JSON.stringify(payload) }], - structuredContent: payload, - }; - } - - if ( - typeof data === "object" && - data !== null && - "result_type" in data && - data.result_type === "http_get_url" - ) { - const payload = gpfGetFeaturesHttpGetUrlOutputSchema.parse(data); - - return { - content: [{ type: "text" as const, text: JSON.stringify(payload) }], - structuredContent: payload, + content: [{ type: "text" as const, text: JSON.stringify(data) }], + structuredContent: data as Record, }; } @@ -115,13 +95,6 @@ class GpfGetFeaturesTool extends BaseTool { input: validatedInput }); - if (validatedInput.result_type === "http_post_request" || validatedInput.result_type === "http_get_url") { - const { request } = await prepareQueryFeaturesRequest(validatedInput); - return validatedInput.result_type === "http_post_request" - ? toWfsHttpPostRequestPayload(request) - : toWfsHttpGetUrlPayload(request); - } - return executeQueryFeatures(validatedInput); } } diff --git a/src/wfs/byId.ts b/src/wfs/byId.ts index e0750ae4..aaf89b9f 100644 --- a/src/wfs/byId.ts +++ b/src/wfs/byId.ts @@ -133,7 +133,7 @@ export function requireSingleFeatureById( // --- Results Execution --- /** - * Executes the structured WFS by-id flow for `result_type="results"`. + * Executes the structured WFS by-id flow for. * * This function: * - loads the feature type from the embedded catalog @@ -166,12 +166,20 @@ export async function executeGetFeatureById( }); const firstFeature = requireSingleFeatureById(featureCollection, input); + const propertyNameWithGeometry = buildPropertyName(featureType, { + includeGeometry: true, + select: input.select, + }); + const requestWithGeometry = buildGetFeatureByIdRequest(input.typename, input.feature_id, propertyNameWithGeometry); + const url = requestWithGeometry.get_url // TODO: replace with API URL + const singleFeatureCollection = { ...featureCollection, features: [firstFeature], totalFeatures: 1, numberReturned: 1, numberMatched: 1, + collection_url: url, }; return attachFeatureRefs(singleFeatureCollection, input.typename, input.spatial_extras); diff --git a/src/wfs/features.ts b/src/wfs/features.ts index ebff95f4..12a6d47b 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -27,22 +27,12 @@ import { getMatchedFeatureCount } from "./response.js"; import type { WfsFeatureCollectionResponse } from "./types.js"; import { buildMainRequest, - type CompiledRequest, } from "./request.js"; import { attachFeatureRefs } from "./response.js"; import type { GpfQueryFeaturesInput } from "./schema.js"; // --- Types --- -/** - * Prepared request context returned once the `get_features` input has been - * validated, compiled, and assembled into a live WFS request. - */ -export type PreparedGetFeaturesRequest = { - compiled: CompiledQuery; - request: CompiledRequest; -}; - type GeometryLike = { type: string; coordinates: unknown; @@ -192,7 +182,7 @@ export async function resolveSpatialFilterGeometry( */ export async function prepareQueryFeaturesRequest( input: GpfQueryFeaturesInput -): Promise { +): Promise { // TODO: Assess if this guard does not prevent legitimate use cases. ensureIntersectsFeatureTargetsOtherTypename(input); // Get the feature type definition from the embedded catalog to access @@ -203,10 +193,8 @@ export async function prepareQueryFeaturesRequest( // Compile query fragments from the normalized input, feature type, and // optional resolved reference geometry. const compiled = compileQueryParts(input, featureType, resolvedGeometryRef); - // Assemble the final WFS request from the compiled fragments. - const request = buildMainRequest(input, compiled); - return { compiled, request }; + return compiled; } // --- Execution --- @@ -223,7 +211,8 @@ export async function prepareQueryFeaturesRequest( * @returns Either a hit-count payload or a transformed FeatureCollection. */ export async function executeQueryFeatures(input: GpfQueryFeaturesInput) { - const { compiled, request } = await prepareQueryFeaturesRequest(input); + const compiled = await prepareQueryFeaturesRequest(input); + const request = buildMainRequest(input, compiled); let featureCollection: WfsFeatureCollectionResponse; @@ -247,6 +236,10 @@ export async function executeQueryFeatures(input: GpfQueryFeaturesInput) { throw error; } + const compiledWithGeometry = { ...compiled, propertyName: compiled.propertyNamesWithGeom }; + const requestWithGeometry = buildMainRequest(input, compiledWithGeometry) + featureCollection.collection_url = requestWithGeometry.get_url; // TODO: replace with API URL + if (isGetFeaturesQuery) { return attachFeatureRefs(featureCollection, input.typename, input.spatial_extras); } else { diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts index 2dc832a6..aec67dd6 100644 --- a/src/wfs/properties.ts +++ b/src/wfs/properties.ts @@ -113,58 +113,42 @@ export function validateSelectProperty(featureType: Collection, geometryProperty // --- Property Selection --- /** - * Builds the list of property names to return according to `select` and `result_type`. + * Builds the list of property names to return according to `select`. * * Note that: - * - when `select` is omitted and `result_type` is `results`, every non-geometric property is returned + * - when `select` is omitted, every non-geometric property is returned * - when `select` is provided, each property is validated against the embedded catalog - * - when `result_type` is an HTTP preview mode, the geometry column is appended to the requested selection - * - when `result_type` is `results` and `spatial_extras` is non-empty, the geometry column is also appended so elements of GPF_GET_FEATURES_SPATIAL_EXTRAS (bbox, centroid, ...) can be derived * * @param featureType Feature type definition loaded from the embedded catalog. * @param geometryProperty Geometry property already resolved for the feature type. * @param input Normalized tool input. - * @returns The list of property names to expose in the WFS `propertyName` parameter. + * @returns The list of property names to expose in the WFS `propertyName` parameter, and the same list including the geometry */ export function buildSelectList( featureType: Collection, geometryProperty: CollectionProperty, input: GpfGetFeaturesInput, ) { - const shouldIncludeGeometry = - (input.result_type === "http_post_request" || input.result_type === "http_get_url") || - (input.result_type === "results" && (input.spatial_extras ?? []).length > 0); - - // If `select` is specified, only the requested properties are returned - // after validation against the embedded catalog. - if (input.select && input.select.length > 0) { - const selectedProperties = input.select.map((propertyName) => + const shouldIncludeGeometry = (input.spatial_extras ?? []).length > 0; + const hasExplicitSelect = Boolean(input.select && input.select.length > 0); + const baseSelection = hasExplicitSelect + ? input.select!.map((propertyName) => validateSelectProperty(featureType, geometryProperty, propertyName), - ); - - // Include geometry when requested by output mode or by spatial_extras. - if (shouldIncludeGeometry) { - return [...selectedProperties, geometryProperty.name]; - } - - return selectedProperties; - } - - // If `select` is omitted and `result_type="results"`, return every - // non-geometric property from the feature type. - if (input.result_type === "results") { - const nonGeometryProperties = featureType.properties + ) + : featureType.properties .filter((property: CollectionProperty) => !property.defaultCrs) .map((property: CollectionProperty) => property.name); - if (shouldIncludeGeometry) { - return [...nonGeometryProperties, geometryProperty.name]; - } + const selection = shouldIncludeGeometry + ? [...baseSelection, geometryProperty.name] + : baseSelection; - return nonGeometryProperties; - } + const withGeometry = hasExplicitSelect || shouldIncludeGeometry + ? [...baseSelection, geometryProperty.name] + : []; // empty list means include everything - // If `select` is omitted and `result_type` is an HTTP preview mode, - // do not send any `propertyName` selection. - return []; + return { + selection, + withGeometry, + }; } diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts index abfe9ecf..7a60aa0c 100644 --- a/src/wfs/queryPreparation.ts +++ b/src/wfs/queryPreparation.ts @@ -67,6 +67,7 @@ export type CompiledQuery = { geometryProperty: CollectionProperty; cqlFilter?: string; propertyName?: string; + propertyNamesWithGeom?: string; sortBy?: string; }; @@ -229,12 +230,16 @@ export function compileQueryParts( ? input.order_by.map((clause) => compileOrderByClause(featureType, geometryProperty, clause)).join(",") : undefined; - const propertyNames = isGetFeaturesQuery ? buildSelectList(featureType, geometryProperty, input) : []; + const propertyNames = isGetFeaturesQuery ? buildSelectList(featureType, geometryProperty, input) : { + selection: [], + withGeometry: [], + }; return { geometryProperty, cqlFilter: fragments.length > 0 ? fragments.join(" AND ") : undefined, - propertyName: propertyNames.length > 0 ? propertyNames.join(",") : undefined, + propertyName: propertyNames.selection.length > 0 ? propertyNames.selection.join(",") : undefined, + propertyNamesWithGeom: propertyNames.withGeometry.length > 0 ? propertyNames.withGeometry.join(",") : undefined, sortBy, }; } diff --git a/src/wfs/request.ts b/src/wfs/request.ts index 20f64c1d..03a1361e 100644 --- a/src/wfs/request.ts +++ b/src/wfs/request.ts @@ -23,65 +23,6 @@ export type CompiledRequest = WfsRequestTransport & { get_url: string; }; -export type WfsHttpPostRequestPayload = { - result_type: "http_post_request"; - http_post_request: { - method: "POST"; - url: string; - headers: { "Content-Type": "application/x-www-form-urlencoded" }; - body: string; - }; -}; - -export type WfsHttpGetUrlPayload = { - result_type: "http_get_url"; - http_get_url: string; -}; - -// --- Request Payload Mapping --- - -/** - * Builds a full URL from base endpoint and query parameters. - * - * @param url Base WFS endpoint URL. - * @param query Query-string parameters sent with the request. - * @returns URL with encoded query-string parameters. - */ -function buildUrlWithQuery(url: string, query: Record) { - return `${url}?${new URLSearchParams(query).toString()}`; -} - -/** - * Maps a compiled WFS request to the compact MCP POST payload. - * - * @param request Compiled request ready to be executed against the WFS service. - * @returns A normalized POST payload exposed by MCP tools. - */ -export function toWfsHttpPostRequestPayload(request: CompiledRequest): WfsHttpPostRequestPayload { - return { - result_type: "http_post_request", - http_post_request: { - method: request.method, - url: buildUrlWithQuery(request.url, request.query), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: request.body, - }, - }; -} - -/** - * Maps a compiled WFS request to the compact MCP GET URL payload. - * - * @param request Compiled request ready to be serialized as an equivalent GET URL. - * @returns A normalized GET URL payload exposed by MCP tools. - */ -export function toWfsHttpGetUrlPayload(request: CompiledRequest): WfsHttpGetUrlPayload { - return { - result_type: "http_get_url", - http_get_url: request.get_url, - }; -} - // --- Request Assembly Helpers --- /** @@ -100,8 +41,7 @@ function buildBody(cqlFilter?: string) { /** * Builds the equivalent GET URL variant of the request. * - * Consumers should prefer `http_post_request` for robust direct WFS execution - * when this URL is very long or contains a large `cql_filter`. + * TODO: replace this by the API URL * * @param url Base WFS endpoint URL. * @param query Query-string parameters sent with the request. diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index c3026347..d234854a 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import { generatePublishedInputSchema } from "../helpers/jsonSchema.js"; -import { lonSchema, latSchema } from "../helpers/schemas.js"; +import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; import { TRAVEL_TIME_MAX_MINUTES, TRAVEL_TIME_PROFILES } from "../gpf/navigation.js"; // --- Shared Constants --- @@ -91,30 +91,6 @@ const travelTimeFilterSchema = z.object({ .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."), }).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point."); -// --- Shared Compact Outputs --- - -const wfsHttpPostRequestOutputSchema = z.object({ - result_type: z.literal("http_post_request").describe("Indique que la réponse contient une requête POST robuste à exécuter par un client HTTP."), - http_post_request: z.object({ - method: z.literal("POST").describe("Méthode HTTP à utiliser."), - url: z.string().url().describe("L'URL de la requête."), - headers: z.object({ - "Content-Type": z.literal("application/x-www-form-urlencoded").describe("Type de contenu du corps POST."), - }).strict().describe("En-têtes HTTP à envoyer avec la requête POST."), - body: z.string().describe("Corps de la requête POST, encodé en `application/x-www-form-urlencoded`."), - }).strict().describe("Requête HTTP POST complète à utiliser pour appeler directement le service pourvoyeur."), -}); - -const wfsHttpGetUrlOutputSchema = z.object({ - result_type: z.literal("http_get_url").describe("Indique que la réponse contient l'URL GET équivalente."), - http_get_url: z.string().url().describe("URL GET complète avec tous les paramètres. Utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant; pour une exécution HTTP robuste, préférer `http_post_request` car l'URL renvoyée ici peut être trop longue."), -}); - -export const gpfGetFeaturesHttpPostRequestOutputSchema = wfsHttpPostRequestOutputSchema; -export const gpfGetFeaturesHttpGetUrlOutputSchema = wfsHttpGetUrlOutputSchema; -export const gpfGetFeatureByIdHttpPostRequestOutputSchema = wfsHttpPostRequestOutputSchema; -export const gpfGetFeatureByIdHttpGetUrlOutputSchema = wfsHttpGetUrlOutputSchema; - // --- Shared GPF Inputs --- const gpfTypenameInputSchema = z.object({ @@ -171,20 +147,26 @@ function assertSpatialFilterExclusion(input : Record, ctx : z.R } } -type GeometryExtraRefinementInput = { - spatial_extras: z.output["spatial_extras"]; - result_type: z.output["result_type"]; -}; - -function assertGeometryExtraQuery(input: GeometryExtraRefinementInput, ctx: z.RefinementCtx) { - if (input.spatial_extras.length > 0 && input.result_type !== "results") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["spatial_extras"], - message: "`spatial_extras` ne peut être utilisé qu'avec `result_type=results`. Dans les cas `http_post_request` et `http_get_url`, la géométrie complète est renvoyée par la requête." - }); - } -} +// --- Shared GPF outputs --- + +const featureResultSchema = z + .object({ + type: z.literal("Feature").describe("Le type GeoJSON."), + id: z.string().describe("L'identifiant de l'objet."), + feature_ref: featureRefSchema.describe("Référence GPF réutilisable, notamment avec `gpf_get_features` et `intersects_feature_filter`."), + properties: z.object({}).catchall(z.unknown()).describe("Les attributs de l'objet."), // in the catchall + geometry: z.null().describe("La géométrie de l'objet, non incluse. Celle-ci est accessible au lien `collection_url`."), + }) + .catchall(z.unknown()); + +const featureCollectionCommonSchema = z.object({ + type: z.literal("FeatureCollection").describe("Le type GeoJSON."), + totalFeatures: z.number().describe("Le nombre de résultats correspondant à la requête sur le serveur."), + numberMatched: z.number().describe("Le nombre de résultats correspondant à la requête sur le serveur."), // FIXME: put the right definition + numberReturned: z.number().describe("Le nombre de résultats renvoyés, au maximum `limit`"), + timeStamp: z.date().describe("La date et l'heure de la requête."), + collection_url: z.string().url().describe("Le lien vers le GeoJSON qui inclut les géométries."), +}); // --- Shared GPF types --- @@ -207,7 +189,7 @@ export const gpfGetFeaturesInputObjectSchema = gpfTypenameInputSchema .array(z.string().trim().min(1)) .min(1) .optional() - .describe("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\"]`."), + .describe("Liste des attributs (à l'exception de la géométrie) à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`."), })) .merge(gpfWhereFilterInputSchema) .merge(gpfSpatialFilterInputSchema) @@ -219,10 +201,6 @@ export const gpfGetFeaturesInputObjectSchema = gpfTypenameInputSchema .max(MAX_LIMIT) .default(DEFAULT_LIMIT) .describe(`Nombre maximum d'objets à renvoyer. Valeur par défaut : ${DEFAULT_LIMIT}. Maximum : ${MAX_LIMIT}.`), - result_type: z - .enum(["results", "http_post_request", "http_get_url"]) - .default("results") - .describe("`results` renvoie une FeatureCollection avec les propriétés attributaires uniquement — **les géométries ne sont pas incluses**, ce mode ne peut donc pas être utilisé directement pour cartographier. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant. Avec `http_post_request` ou `http_get_url`, la géométrie est automatiquement ajoutée aux propriétés du `select` pour garantir l'affichage cartographique."), order_by: z .array(orderBySchema) .min(1) @@ -234,7 +212,12 @@ export const gpfGetFeaturesInputObjectSchema = gpfTypenameInputSchema export const gpfGetFeaturesInputSchema = gpfGetFeaturesInputObjectSchema .superRefine(assertSpatialFilterExclusion) - .superRefine(assertGeometryExtraQuery); + +export const getFeaturesOutputSchema = featureCollectionCommonSchema + .merge(z.object({ + features: z.array(featureResultSchema).describe("La liste des objets correspondant à la requête."), + }) +); // --- `gpf_get_features` Types --- @@ -286,21 +269,24 @@ export const gpfGetFeatureByIdInputObjectSchema = z.object({ .trim() .min(1, "le feature_id ne doit pas être vide") .describe("Identifiant GPF exact de l'objet à récupérer, par exemple `commune.8952`."), - result_type: z - .enum(["results", "http_post_request", "http_get_url"]) - .default("results") - .describe("`results` renvoie une FeatureCollection normalisée avec exactement un objet et le choix de `spatial_extras` en guise d'information géométrique. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant."), select: z .array(z.string().trim().min(1)) .min(1) .optional() - .describe("Liste des propriétés non géométriques à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`."), + .describe("Liste des attributs (à l'exception de la géométrie) à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`."), }) .merge(gpfGeometryExtraInputSchema) .strict(); -export const gpfGetFeatureByIdInputSchema = gpfGetFeatureByIdInputObjectSchema - .superRefine(assertGeometryExtraQuery); +export const gpfGetFeatureByIdInputSchema = gpfGetFeatureByIdInputObjectSchema; + +export const getFeatureByIdOutputSchema = featureCollectionCommonSchema + .merge(z.object({ + features: z.array(featureResultSchema).max(1).describe("La liste avec l'objet exact de la requête."), + }) +); + +// --- `gpf_get_feature_by_id` Types --- export type GpfGetFeatureByIdInput = z.infer; diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index 421585c5..c0890ef4 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -130,10 +130,6 @@ describe("generate-mcp-docs helpers", () => { const markdown = renderResponseContractSection({ name: "gpf_get_features", }); - - expect(markdown).toContain('| Succès `result_type="results"` | oui | non | `content[0].text` est la FeatureCollection stringifiée ; aucun `structuredContent` n\'est ajouté dans ce mode. |'); - expect(markdown).toContain('| Succès `result_type="http_post_request"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |'); - expect(markdown).toContain('| Succès `result_type="http_get_url"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |'); }); it("should document shared MCP annotations", async () => { diff --git a/test/tools/wfs/getFeatureById.test.ts b/test/tools/wfs/getFeatureById.test.ts index a5dd88c0..e8b148a6 100644 --- a/test/tools/wfs/getFeatureById.test.ts +++ b/test/tools/wfs/getFeatureById.test.ts @@ -75,12 +75,6 @@ describe("Test GpfGetFeatureByIdTool", () => { default: [], description: "Éléments calculés depuis la géométrie à renvoyer pour `result_type=results`. Peut inclure `centroid` et `bbox`, aucun par défaut.", }, - result_type: { - type: "string", - enum: ["results", "http_post_request", "http_get_url"], - default: "results", - description: "`results` renvoie une FeatureCollection normalisée avec exactement un objet et le choix de `spatial_extras` en guise d'information géométrique. `http_post_request` renvoie une requête POST robuste à exécuter directement. `http_get_url` renvoie l'URL GET équivalente, utile pour les consommateurs URL-first ou pour la visualisation dans un outil la supportant.", - }, select: { type: "array", items: { @@ -88,7 +82,7 @@ describe("Test GpfGetFeatureByIdTool", () => { minLength: 1, }, minItems: 1, - description: "Liste des propriétés non géométriques à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`.", + description: "Liste des attributs (à l'exception de la géométrie) à renvoyer. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `[\"code_insee\", \"nom_officiel\"]`.", }, }, required: ["typename", "feature_id"], @@ -372,95 +366,6 @@ describe("Test GpfGetFeatureByIdTool", () => { }); }); - it("should reject invalid result_type values such as hits", async () => { - const tool = new GpfGetFeatureByIdTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_get_feature_by_id", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - feature_id: "commune.1", - 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("Paramètres invalides"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "result_type", - code: "invalid_enum_value", - detail: expect.stringContaining("results"), - }), - ]), - }); - }); - - it("should reject legacy request result_type", async () => { - const tool = new GpfGetFeatureByIdTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_get_feature_by_id", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - feature_id: "commune.1", - result_type: "request", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "result_type", - code: "invalid_enum_value", - detail: expect.stringContaining("http_post_request"), - }), - ]), - }); - }); - - it("should reject spatial_extras with http_get_url result_type", async () => { - const tool = new GpfGetFeatureByIdTool(); - - const response = await tool.toolCall({ - params: { - name: "gpf_get_feature_by_id", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - feature_id: "commune.1", - spatial_extras: ["bbox"], - result_type: "http_get_url", - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("spatial_extras"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "spatial_extras", - code: "custom", - }), - ]), - }); - }); - it("should fail clearly when execution returns an unexpected success payload", async () => { const tool = new InvalidSuccessPayloadTool(); diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index 18798a16..f35f349c 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -229,7 +229,7 @@ describe("Test GpfGetFeaturesTool", () => { }); }); - it("should return text content and structuredContent for http_post_request", async () => { + it("should return text content and structuredContent", async () => { const tool = new GpfGetFeaturesTool(); mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); @@ -238,7 +238,6 @@ describe("Test GpfGetFeaturesTool", () => { name: "gpf_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "http_post_request", select: ["code_insee"], where: [ { @@ -261,58 +260,6 @@ describe("Test GpfGetFeaturesTool", () => { } const payload = JSON.parse(textContent.text); expect(payload).toEqual(response.structuredContent); - expect(payload.result_type).toEqual("http_post_request"); - expect(payload.http_get_url).toBeUndefined(); - expect(payload.http_post_request).toMatchObject({ - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: expect.stringContaining("cql_filter="), - }); - const url = new URL(payload.http_post_request.url); - expect(url.origin + url.pathname).toEqual("https://data.geopf.fr/wfs"); - expect(url.searchParams.get("service")).toEqual("WFS"); - expect(url.searchParams.get("exceptions")).toEqual("application/json"); - expect(url.searchParams.get("propertyName")).toEqual("code_insee,geometrie"); - expect(url.searchParams.get("cql_filter")).toBeNull(); - }); - - it("should return text content and structuredContent for http_get_url", async () => { - const tool = new GpfGetFeaturesTool(); - mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "http_get_url", - select: ["code_insee"], - where: [ - { - property: "code_insee", - operator: "eq", - value: "01001", - }, - ], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const payload = JSON.parse(textContent.text); - expect(payload).toEqual(response.structuredContent); - expect(payload).toMatchObject({ - result_type: "http_get_url", - http_get_url: expect.stringContaining("https://data.geopf.fr/wfs?"), - }); - expect(payload.http_post_request).toBeUndefined(); - const url = new URL(payload.http_get_url); - expect(url.searchParams.get("propertyName")).toEqual("code_insee,geometrie"); - expect(url.searchParams.get("cql_filter")).toContain("code_insee = '01001'"); }); it("should compile travel_time_filter into a WFS request using an isochrone geometry", async () => { @@ -387,63 +334,6 @@ describe("Test GpfGetFeaturesTool", () => { }); }); - it("should reject legacy request result_type", async () => { - const tool = new GpfGetFeaturesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "request", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "result_type", - code: "invalid_enum_value", - detail: expect.stringContaining("http_post_request"), - }), - ]), - }); - }); - - it("should reject spatial_extras with http_get_url result_type", async () => { - const tool = new GpfGetFeaturesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "http_get_url", - spatial_extras: ["bbox"], - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("spatial_extras"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "spatial_extras", - code: "custom", - }), - ]), - }); - expect(mockGetFeatureType).not.toHaveBeenCalled(); - expect(mockFetchJSONPost).not.toHaveBeenCalled(); - }); - it("should reject multiple spatial filters as invalid tool parameters", 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 b5df64e0..6431e5cb 100644 --- a/test/wfs/queryPreparation.test.ts +++ b/test/wfs/queryPreparation.test.ts @@ -25,7 +25,6 @@ describe("gpfGetFeatures/queryPreparation", () => { const baseInput: GpfGetFeaturesInput = { typename: "ADMINEXPRESS-COG.LATEST:commune", limit: 100, - result_type: "results", spatial_extras: [] }; @@ -115,16 +114,6 @@ describe("gpfGetFeatures/queryPreparation", () => { }, featureType)).toThrow("`select` accepte uniquement"); }); - it("should append geometry to propertyName for HTTP preview modes when select is provided", () => { - const compiled = compileQueryParts({ - ...baseInput, - result_type: "http_post_request", - select: ["code_insee", "population"], - }, featureType); - - expect(compiled.propertyName).toEqual("code_insee,population,geometrie"); - }); - it("should build sortBy from structured order_by", () => { const compiled = compileQueryParts({ ...baseInput, diff --git a/test/wfs/spatialFilter.test.ts b/test/wfs/spatialFilter.test.ts index 7c64b573..bc720e19 100644 --- a/test/wfs/spatialFilter.test.ts +++ b/test/wfs/spatialFilter.test.ts @@ -9,7 +9,6 @@ import { const baseInput: GpfGetFeaturesInput = { typename: "ADMINEXPRESS-COG.LATEST:commune", limit: 100, - result_type: "results", spatial_extras: [], }; From ab7ac58649714eedcda6ced0a1b966633d590e85 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 3 Jul 2026 16:37:39 +0200 Subject: [PATCH 2/2] chore: Fix tests --- test/scripts/generate-mcp-docs.test.ts | 4 ++ test/tools/wfs/getFeatureById.test.ts | 63 ++++++++++---------------- test/tools/wfs/getFeatures.test.ts | 50 ++++++++++++-------- 3 files changed, 60 insertions(+), 57 deletions(-) diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts index c0890ef4..2219c26f 100644 --- a/test/scripts/generate-mcp-docs.test.ts +++ b/test/scripts/generate-mcp-docs.test.ts @@ -130,6 +130,10 @@ describe("generate-mcp-docs helpers", () => { const markdown = renderResponseContractSection({ name: "gpf_get_features", }); + + expect(markdown).toContain("### Réponse MCP"); + expect(markdown).toContain("| Succès | oui |"); + expect(markdown).not.toContain("result_type"); }); it("should document shared MCP annotations", async () => { diff --git a/test/tools/wfs/getFeatureById.test.ts b/test/tools/wfs/getFeatureById.test.ts index e8b148a6..74b78cf0 100644 --- a/test/tools/wfs/getFeatureById.test.ts +++ b/test/tools/wfs/getFeatureById.test.ts @@ -91,9 +91,24 @@ describe("Test GpfGetFeatureByIdTool", () => { }); }); - it("should return text content and structuredContent for http_post_request", async () => { + it("should return text content and structuredContent for selected properties", async () => { const tool = new GpfGetFeatureByIdTool(); mockGetFeatureType.mockResolvedValue(polygonFeatureType); + mockFetchJSONPost.mockResolvedValue({ + type: "FeatureCollection", + totalFeatures: 1, + numberMatched: 1, + numberReturned: 1, + timeStamp: "2026-01-01T00:00:00.000Z", + features: [ + { + type: "Feature", + id: "commune.1", + geometry: { type: "MultiPolygon", coordinates: [] }, + properties: { code_insee: "01001" }, + }, + ], + }); const response = await tool.toolCall({ params: { @@ -101,7 +116,6 @@ describe("Test GpfGetFeatureByIdTool", () => { arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", feature_id: "commune.1", - result_type: "http_post_request", select: ["code_insee"], }, }, @@ -114,14 +128,12 @@ describe("Test GpfGetFeatureByIdTool", () => { } const payload = JSON.parse(textContent.text); expect(payload).toEqual(response.structuredContent); - expect(payload.result_type).toEqual("http_post_request"); - expect(payload.http_get_url).toBeUndefined(); - expect(payload.http_post_request).toMatchObject({ - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: "", + expect(payload.features).toHaveLength(1); + expect(payload.features[0].feature_ref).toEqual({ + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", }); - const url = new URL(payload.http_post_request.url); + const url = new URL(payload.collection_url); expect(url.searchParams.get("featureID")).toEqual("commune.1"); expect(url.searchParams.get("typeNames")).toEqual("ADMINEXPRESS-COG.LATEST:commune"); expect(url.searchParams.get("exceptions")).toEqual("application/json"); @@ -129,36 +141,6 @@ describe("Test GpfGetFeatureByIdTool", () => { expect(url.searchParams.get("count")).toEqual("2"); }); - it("should return text content and structuredContent for http_get_url", async () => { - const tool = new GpfGetFeatureByIdTool(); - mockGetFeatureType.mockResolvedValue(polygonFeatureType); - - const response = await tool.toolCall({ - params: { - name: "gpf_get_feature_by_id", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - feature_id: "commune.1", - result_type: "http_get_url", - select: ["code_insee"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const payload = JSON.parse(textContent.text); - expect(payload).toEqual(response.structuredContent); - expect(payload.result_type).toEqual("http_get_url"); - expect(payload.http_post_request).toBeUndefined(); - const url = new URL(payload.http_get_url); - expect(url.searchParams.get("featureID")).toEqual("commune.1"); - expect(url.searchParams.get("typeNames")).toEqual("ADMINEXPRESS-COG.LATEST:commune"); - expect(url.searchParams.get("propertyName")).toEqual("code_insee,geometrie"); - }); it("should return exactly one transformed feature for results", async () => { const tool = new GpfGetFeatureByIdTool(); @@ -173,6 +155,9 @@ describe("Test GpfGetFeatureByIdTool", () => { return { type: "FeatureCollection", totalFeatures: 1, + numberMatched: 1, + numberReturned: 1, + timeStamp: "2026-01-01T00:00:00.000Z", features: [ { type: "Feature", diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index f35f349c..d1280b32 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -85,6 +85,9 @@ describe("Test GpfGetFeaturesTool", () => { }; }>; totalFeatures: number; + numberMatched: number; + numberReturned: number; + timeStamp: string; } = { type: "FeatureCollection", features: [ @@ -98,6 +101,9 @@ describe("Test GpfGetFeaturesTool", () => { }, ], totalFeatures: 34877, + numberMatched: 34877, + numberReturned: 1, + timeStamp: "2026-01-01T00:00:00.000Z", }; function mockFeatureTypes(featureTypes: Record) { @@ -184,7 +190,13 @@ describe("Test GpfGetFeaturesTool", () => { expect(tool.toolDefinition.inputSchema.properties?.where).toMatchObject({ type: "array", }); - expect(tool.toolDefinition.outputSchema).toBeUndefined(); + expect(tool.toolDefinition.outputSchema).toMatchObject({ + type: "object", + properties: expect.objectContaining({ + collection_url: expect.objectContaining({ type: "string" }), + features: expect.objectContaining({ type: "array" }), + }), + }); }); it("should publish an LLM-compatible input schema without composition keywords", () => { @@ -210,12 +222,12 @@ describe("Test GpfGetFeaturesTool", () => { }); }); - it("should return a FeatureCollection without structuredContent for results", () => { + it("should return a FeatureCollection with structuredContent for results", () => { const tool = new RespondableGpfGetFeaturesTool(); const response = tool.respond(featureCollection as never); expect("isError" in response).toBe(false); - expect(response.structuredContent).toBeUndefined(); + expect(response.structuredContent).toEqual(featureCollection); expect(response.content[0]).toMatchObject({ type: "text", }); @@ -232,6 +244,7 @@ describe("Test GpfGetFeaturesTool", () => { it("should return text content and structuredContent", async () => { const tool = new GpfGetFeaturesTool(); mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); + captureRequests(featureCollection); const response = await tool.toolCall({ params: { @@ -266,13 +279,13 @@ describe("Test GpfGetFeaturesTool", () => { const tool = new GpfGetFeaturesTool(); mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); const isochroneUrls = captureIsochroneRequests(); + captureRequests(featureCollection); const response = await tool.toolCall({ params: { name: "gpf_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "http_post_request", travel_time_filter: { lon: 2.337306, lat: 48.849319, @@ -290,16 +303,11 @@ describe("Test GpfGetFeaturesTool", () => { expect(isochroneUrl.searchParams.get("costType")).toEqual("time"); expect(isochroneUrl.searchParams.get("costValue")).toEqual("15"); expect(isochroneUrl.searchParams.get("profile")).toEqual("pedestrian"); - - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const payload = JSON.parse(textContent.text); - expect(new URLSearchParams(payload.http_post_request.body).get("cql_filter")).toEqual( + expect(mockFetchJSONPost).toHaveBeenCalledTimes(1); + const sentBody = mockFetchJSONPost.mock.calls[0]?.[1] ?? ""; + expect(new URLSearchParams(sentBody).get("cql_filter")).toEqual( "INTERSECTS(geometrie,SRID=4326;POLYGON((2 48,2.2 48,2.2 48.2,2 48)))", ); - expect(mockFetchJSONPost).not.toHaveBeenCalled(); }); it("should return isError=true for invalid input", async () => { @@ -383,7 +391,7 @@ describe("Test GpfGetFeaturesTool", () => { name: "gpf_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - cql_filter: "code_insee = '01001'", + result_type: "http_get_url", }, }, }); @@ -399,8 +407,8 @@ describe("Test GpfGetFeaturesTool", () => { errors: expect.arrayContaining([ expect.objectContaining({ code: "unknown_parameter", - name: "cql_filter", - detail: expect.stringContaining("cql_filter"), + name: "result_type", + detail: expect.stringContaining("result_type"), }), ]), }); @@ -575,6 +583,9 @@ describe("Test GpfGetFeaturesTool", () => { }, ], totalFeatures: 1, + numberMatched: 1, + numberReturned: 1, + timeStamp: "2026-01-01T00:00:00.000Z", }); const response = await tool.toolCall({ @@ -618,6 +629,9 @@ describe("Test GpfGetFeaturesTool", () => { }, ], totalFeatures: 1, + numberMatched: 1, + numberReturned: 1, + timeStamp: "2026-01-01T00:00:00.000Z", }); const response = await tool.toolCall({ @@ -629,19 +643,19 @@ describe("Test GpfGetFeaturesTool", () => { typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", feature_id: "localisant.1", }, - result_type: "http_post_request", }, }, }); expect(response.isError).toBeUndefined(); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(2); const textContent = response.content[0]; if (textContent.type !== "text") { throw new Error("expected text content"); } const payload = JSON.parse(textContent.text); - expect(payload.http_post_request.body).toContain("MULTIPOINT"); + expect(payload.features).toHaveLength(1); + expect(requests[1].body).toContain("MULTIPOINT"); }); it("should report missing reference features clearly for intersects_feature", async () => {