From 518bc9732b252234b9e21446b8f4748dd0c3ffcb Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 14 Aug 2026 12:45:56 +0200 Subject: [PATCH 1/5] fix: openWorldHint should be false for SearchTypes, DescribeType and GetFeatureByIdLayer tools --- src/helpers/toolAnnotations.ts | 7 +++++++ src/tools/GpfDescribeTypeTool.ts | 4 ++-- src/tools/GpfGetFeatureByIdLayerTool.ts | 4 ++-- src/tools/GpfSearchTypesTool.ts | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/helpers/toolAnnotations.ts b/src/helpers/toolAnnotations.ts index 9a7a651a..ef801c6b 100644 --- a/src/helpers/toolAnnotations.ts +++ b/src/helpers/toolAnnotations.ts @@ -4,3 +4,10 @@ export const READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS = { idempotentHint: true, openWorldHint: true, }; + +export const READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}; diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index ae5e5c86..ca50e69f 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { zOgcCollectionSchema } from "@ignfab/gpf-schema-store"; import { wfsSchemaStore } from "../wfs/catalog.js"; -import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import logger from "../logger.js"; // --- Schema --- @@ -36,7 +36,7 @@ type GpfDescribeTypeInput = z.infer; class GpfDescribeTypeTool extends BaseTool { name = "gpf_describe_type"; title = "Description d’un type GPF"; - annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; description = [ "Renvoie le schéma détaillé d'un type GPF à partir de son identifiant (`typename`).", "Ce schéma contient notamment la description du type et un champ `properties` qui détaille, pour chaque propriété, son type, sa description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.", diff --git a/src/tools/GpfGetFeatureByIdLayerTool.ts b/src/tools/GpfGetFeatureByIdLayerTool.ts index 8c7b5392..fde43606 100644 --- a/src/tools/GpfGetFeatureByIdLayerTool.ts +++ b/src/tools/GpfGetFeatureByIdLayerTool.ts @@ -27,7 +27,7 @@ import BaseTool from "./BaseTool.js"; -import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import { getEnv } from "../config/env.js"; import { encodeToken } from "../proxy/token.js"; import { buildDataUrl } from "../proxy/dataUrl.js"; @@ -47,7 +47,7 @@ import logger from "../logger.js"; class GpfGetFeatureByIdLayerTool extends BaseTool { name = "gpf_get_feature_by_id_layer"; title = "Couche cartographiable d’un objet GPF par identifiant"; - annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; description = [ "Renvoie une **URL de couche cartographiable** (`data_url`) pour exactement un objet GPF, identifié par `typename` et `feature_id` : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON contenant le seul objet demandé, avec sa géométrie complète.", "C'est le pendant cartographique de `gpf_get_feature_by_id` : utiliser ce tool dès qu'il faut **afficher / cartographier** un objet précis dont on connaît déjà la `feature_ref { typename, feature_id }` (issue d'un autre tool : `adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`, `gpf_get_features`). Pour récupérer ses attributs sans géométrie, utiliser `gpf_get_feature_by_id`.", diff --git a/src/tools/GpfSearchTypesTool.ts b/src/tools/GpfSearchTypesTool.ts index 8a71a2f3..63f0e8f9 100644 --- a/src/tools/GpfSearchTypesTool.ts +++ b/src/tools/GpfSearchTypesTool.ts @@ -5,7 +5,7 @@ import BaseTool from "./BaseTool.js"; import { z } from "zod"; -import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import { wfsSchemaStore } from "../wfs/catalog.js"; import type { DetailedCollectionSearchMatch } from "../wfs/catalog.js"; import logger from "../logger.js"; @@ -65,7 +65,7 @@ const gpfSearchTypesOutputSchema = z.object({ class GpfSearchTypesTool extends BaseTool { name = "gpf_search_types"; title = "Recherche de types GPF"; - annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; description = [ "Recherche des types de la Géoplateforme (GPF) à partir de mots-clés afin de trouver un identifiant de type (`typename`) valide.", "La recherche est textuelle (mini-search) et retourne une liste ordonnée de candidats avec leur identifiant, leur titre, leur description et un score de pertinence éventuel.", From 15d5671c12fe3d6bd0c5f7136c9a00d9956657a6 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 14 Aug 2026 16:49:04 +0200 Subject: [PATCH 2/5] feat: reduce output of GpfDescribeType and add new GpfDescribeTypeDetails tool --- docs/mcp-tools.md | 261 ++++++++++--- src/tools/GpfDescribeTypeDetailsTool.ts | 195 ++++++++++ src/tools/GpfDescribeTypeTool.ts | 95 ++++- src/wfs/properties.ts | 2 +- .../level1-protocol/describe.test.ts | 53 ++- test/integration/samples.ts | 1 + test/tools/wfs/describeType.test.ts | 345 +++++++++++------- test/tools/wfs/describeTypeDetails.test.ts | 247 +++++++++++++ 8 files changed, 990 insertions(+), 209 deletions(-) create mode 100644 src/tools/GpfDescribeTypeDetailsTool.ts create mode 100644 test/tools/wfs/describeTypeDetails.test.ts diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 18b96fb5..7acefc4c 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -38,17 +38,6 @@ Exemple complet généré automatiquement à partir d'un appel de tool invalide } ``` -## Annotations MCP - -Tous les tools exposent les mêmes annotations MCP dans leur définition `tools/list` : - -| Annotation | Valeur | Signification | -| --- | --- | --- | -| `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. | -| `destructiveHint` | non | Le tool n'est pas signalé comme destructif. | -| `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. | -| `openWorldHint` | oui | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | - ## Liste des tools - [`geocode`](#geocode) @@ -64,6 +53,7 @@ Tous les tools exposent les mêmes annotations MCP dans leur définition `tools/ - [`gpf_count_features`](#gpf_count_features) - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) +- [`gpf_describe_type_details`](#gpf_describe_type_details) ## `geocode` @@ -967,9 +957,9 @@ Description d’un type GPF ### Description du tool ``` -Renvoie le schéma détaillé d'un type GPF à partir de son identifiant (`typename`). -Ce schéma contient notamment la description du type et un champ `properties` qui détaille, pour chaque propriété, son type, sa description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée. -Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`. +Renvoie un résumé du schéma d'un type GPF à partir de son identifiant (`typename`). +Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec un début de description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée. +Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`. **IMPORTANT : Appel fortement recommandé si les noms exacts des propriétés ne sont pas connus : un nom de propriété incorrect provoque une erreur**. ``` @@ -1004,15 +994,11 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo | Champ | Type | Requis | Description | | --- | --- | --- | --- | -| `$id` | string | oui | | -| `$schema` | string | oui | | -| `description` | string | oui | | -| `required` | array | oui | | -| `title` | string | oui | | -| `type` | string | oui | | -| `x-ign-representedFeatures` | array | non | | -| `x-ign-selectionCriteria` | string | non | | -| `x-ign-theme` | string | non | | +| `description` | string | non | La description du contenu du type. | +| `geometry_kind` | string (enum) | non | Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme "point-or-multipoint" ou encore "any". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.
Note : si tu as besoin d'une propriété géométrique dans une requête, utilise `spatial_extra` si c'est possible ; sinon, utilise un tool `_layer` pour télécharger la géométrie. Valeurs : point, multipoint, point-or-multipoint, linestring, multilinestring, linestring-or-multilinestring, polygon, multipolygon, polygon-or-multipolygon, geometrycollection, any. | +| `properties` | array | oui | La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles. | +| `typename` | string | oui | L'identifiant du type (de la forme `prefixe:nom`). | +| `url` | string | oui | Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type et gpf_describe_type_details suffisent, ne télécharge le schéma complet que lorsque les résultats ne sont pas satisfaisants. |
Schéma de sortie brut @@ -1021,48 +1007,69 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo { "type": "object", "properties": { - "$schema": { - "type": "string" + "typename": { + "type": "string", + "description": "L'identifiant du type (de la forme `prefixe:nom`)." }, - "$id": { + "url": { "type": "string", + "description": "Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type et gpf_describe_type_details suffisent, ne télécharge le schéma complet que lorsque les résultats ne sont pas satisfaisants.", "format": "uri" }, - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "x-ign-theme": { - "type": "string" - }, "description": { - "type": "string" - }, - "x-ign-selectionCriteria": { - "type": "string" + "type": "string", + "description": "La description du contenu du type." }, - "x-ign-representedFeatures": { - "type": "array", - "items": { - "type": "string" - } + "geometry_kind": { + "type": "string", + "description": "Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise `spatial_extra` si c'est possible ; sinon, utilise un tool `_layer` pour télécharger la géométrie.", + "enum": [ + "point", + "multipoint", + "point-or-multipoint", + "linestring", + "multilinestring", + "linestring-or-multilinestring", + "polygon", + "multipolygon", + "polygon-or-multipolygon", + "geometrycollection", + "any" + ] }, - "required": { + "properties": { "type": "array", + "description": "La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles.", "items": { - "type": "string" + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Le nom de la propriété." + }, + "description_cut": { + "type": "string", + "description": "Les 100 premiers caractères de la description de la propriété.", + "maxLength": 103 + }, + "oneOf": { + "type": "array", + "description": "La liste des valeurs possibles, si elle existe.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] } } }, "required": [ - "$schema", - "$id", - "type", - "title", - "description", - "required" + "typename", + "url", + "properties" ] } ``` @@ -2211,3 +2218,153 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu | --- | --- | --- | --- | | 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_describe_type_details` + +Code Source : [src/tools/GpfDescribeTypeDetailsTool.ts](../src/tools/GpfDescribeTypeDetailsTool.ts) + +### Titre + +Description des propriétés d’un type GPF + +### Description du tool + +``` +Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF. +Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`. +Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`. +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `extra_details` | boolean | oui | Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs. Valeur par défaut : false. | +| `select` | array | oui | La liste des propriétés non géométriques sur lesquelles des détails sont requis. | +| `typename` | string | oui | Le nom du type à décrire (de la forme `prefixe:nom`). | + +
+Schéma d’entrée brut + +```json +{ + "type": "object", + "properties": { + "typename": { + "type": "string", + "description": "Le nom du type à décrire (de la forme `prefixe:nom`).", + "minLength": 1 + }, + "select": { + "type": "array", + "description": "La liste des propriétés non géométriques sur lesquelles des détails sont requis.", + "items": { + "type": "string" + } + }, + "extra_details": { + "type": "boolean", + "description": "Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.", + "default": false + } + }, + "required": [ + "typename", + "select", + "extra_details" + ] +} +``` + +
+ +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `properties` | array | oui | La liste des propriétés non géométriques demandées avec leurs détails. | +| `typename` | string | oui | L'identifiant du type. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "typename": { + "type": "string", + "description": "L'identifiant du type." + }, + "properties": { + "type": "array", + "description": "La liste des propriétés non géométriques demandées avec leurs détails.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Le nom de la propriété." + }, + "description": { + "type": "string", + "description": "La description de la propriété." + }, + "type": { + "type": "string", + "description": "Le type de la propriété." + }, + "required": { + "type": "boolean", + "description": "Indique si la propriété est obligatoirement présente pour tous les objets du type." + }, + "oneOf": { + "type": "array", + "description": "La liste des valeurs possibles, si elle existe.", + "items": { + "type": "object", + "properties": { + "const": { + "type": "string", + "description": "La valeur possible." + }, + "description": { + "type": "string", + "description": "La signification de cette valeur." + } + }, + "required": [ + "const" + ] + } + }, + "extra_detail_fields": { + "type": "array", + "description": "La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "required" + ] + } + } + }, + "required": [ + "typename", + "properties" + ] +} +``` + +
+ +### Réponse MCP + +| Cas | `content` | `structuredContent` | Relation entre `content` et `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`. | diff --git a/src/tools/GpfDescribeTypeDetailsTool.ts b/src/tools/GpfDescribeTypeDetailsTool.ts new file mode 100644 index 00000000..9bf9e5d7 --- /dev/null +++ b/src/tools/GpfDescribeTypeDetailsTool.ts @@ -0,0 +1,195 @@ +/** + * MCP tool exposing focused schema details for selected properties of one GPF type. + */ + +import BaseTool from "./BaseTool.js"; +import { z } from "zod"; + +import type { OgcCollectionProperty, OgcCollectionPropertyEnumValue, OgcCollectionSchema } from "@ignfab/gpf-schema-store"; +import { wfsSchemaStore } from "../wfs/catalog.js"; +import type { GpfFeatureType } from "../wfs/catalog.js"; +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import logger from "../logger.js"; + +// --- Schema --- + +const gpfDescribeTypeDetailsInputSchema = z.object({ + typename: z + .string() + .trim() + .min(1, "le nom du type ne doit pas être vide") + .describe("Le nom du type à décrire (de la forme `prefixe:nom`)."), + select: z + .array(z.string().trim()) + .min(1, "il faut choisir au moins une propriété") + .describe("La liste des propriétés non géométriques sur lesquelles des détails sont requis."), + extra_details: z + .boolean() + .default(false) + .describe("Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.") +}).strict(); + +const gpfPropertyEnumSchema = z.object({ + const: z.string().describe("La valeur possible."), + description: z.string().optional().describe("La signification de cette valeur."), +}).catchall(z.unknown()); // catchall for when extra_details is required + +const gpfPropertyDetailsSchema = z.object({ + name: z.string().describe("Le nom de la propriété."), + description: z.string().optional().describe("La description de la propriété."), + type: z.string().optional().describe("Le type de la propriété."), + required: z.boolean().describe("Indique si la propriété est obligatoirement présente pour tous les objets du type."), + oneOf: z.array(gpfPropertyEnumSchema).optional().describe("La liste des valeurs possibles, si elle existe."), + extra_detail_fields: z.array(z.string()).optional().describe("La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`."), +}).catchall(z.unknown()); // catchall for when extra_details is required + +const gpfDescribeTypeDetailsOutput = z.object({ + typename: z.string().describe("L'identifiant du type."), + properties: z.array(gpfPropertyDetailsSchema).describe("La liste des propriétés non géométriques demandées avec leurs détails."), +}); + +// --- Types --- + +type GpfDescribeTypeDetailsInput = z.infer; +type GpfDescribePropertyDetailsOutput = z.infer; +type GpfDescribePropertyEnumOutput = z.infer; + +// --- Utility --- + +/** + * Lists keys present on a schema fragment but not exposed in the short output. + */ +function getExtraKeys(source: Record, knownKeys: readonly string[]) { + return Object.keys(source).filter((key) => !knownKeys.includes(key)); +} + +/** + * Normalizes oneOf values into the tool output shape. + */ +function extractOneOfDetails(oneOf: OgcCollectionPropertyEnumValue[] | undefined): GpfDescribePropertyEnumOutput[] | undefined { + if (!oneOf) { + return undefined; + } + + return oneOf.map((value: OgcCollectionPropertyEnumValue) => ({ + const: value.const, + description: value.description, + })); +} + +/** + * Lists extra field names available on oneOf values. + */ +function getOneOfExtraDetailFields(oneOf: OgcCollectionPropertyEnumValue[] | undefined) { + if (!oneOf) { + return []; + } + + return Array.from( + new Set( + oneOf.flatMap((value: OgcCollectionPropertyEnumValue) => + getExtraKeys(value as Record, ["const", "title", "description"]), + ), + ), + ); +} + +/** + * Extracts selected details for one non-geometry property. + */ +function extractDetails(name: string, schema: OgcCollectionSchema, extraDetails: boolean) : GpfDescribePropertyDetailsOutput { + const prop: OgcCollectionProperty = schema.properties[name]; + const required = schema.required.includes(name); + + if (extraDetails) { // return the full schema for the property + return { + ...prop, + name, + required, + }; + } + + const oneOf = extractOneOfDetails(prop.oneOf); + + const propertyExtraDetailFields = getExtraKeys( + prop as Record, + ["type", "title", "description", "format", "oneOf", "x-ogc-role"], + ); + const oneOfExtraDetailFields = getOneOfExtraDetailFields(prop.oneOf); + const extra_detail_fields = Array.from(new Set([...propertyExtraDetailFields, ...oneOfExtraDetailFields])); + + return { + name, + description: prop.description, + type: prop.type, + required, + oneOf, + extra_detail_fields, + }; +} + +// --- Tool --- + +class GpfDescribeTypeDetailsTool extends BaseTool { + name = "gpf_describe_type_details"; + title = "Description des propriétés d’un type GPF"; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; + description = [ + "Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF.", + "Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`.", + "Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`." + ].join("\n"); + protected outputSchemaShape = gpfDescribeTypeDetailsOutput; + + schema = gpfDescribeTypeDetailsInputSchema; + + /** + * Formats the details payload into both text content and structuredContent. + * + * @param data Raw execution result. + * @returns An MCP success response with validated output shape. + */ + protected createSuccessResponse(data: unknown) { + const payload = gpfDescribeTypeDetailsOutput.parse(data); + return { + content: [{ type: "text" as const, text: JSON.stringify(payload) }], + structuredContent: payload, + }; + } + + /** + * Loads the detailed schema description for one GPF typename. + * + * @param input Normalized tool input. + * @returns The detailed feature type description from the embedded catalog. + */ + async execute(input: GpfDescribeTypeDetailsInput) { + logger.info(`[tool] execute ${this.name} ...`, { + input: input + }); + + let featureType: GpfFeatureType; + + try { + featureType = await wfsSchemaStore.getFeatureType(input.typename); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`${message}. Utiliser gpf_search_types pour trouver un type valide.`); + } + + const invalidProperties = input.select.filter((name: string) => { + const property = featureType.schema.properties[name]; + return !property || !property.type; + }); + if (invalidProperties.length > 0) { + throw new Error(`Le type ${featureType.typename} n'admet pas, parmi ses propriétés non-géométriques : ${invalidProperties.join(", ")}. Utiliser gpf_describe_type pour obtenir la liste des propriétés non-géométriques disponibles.`); + } + + return { + typename: featureType.typename, + properties: input.select.map((name: string) => extractDetails(name, featureType.schema, input.extra_details)), + }; + } +} + +export default GpfDescribeTypeDetailsTool; diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index ca50e69f..554292bd 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -4,11 +4,12 @@ import BaseTool from "./BaseTool.js"; import { z } from "zod"; -import { zOgcCollectionSchema } from "@ignfab/gpf-schema-store"; -import { wfsSchemaStore } from "../wfs/catalog.js"; +import type { OgcCollectionPropertyEnumValue } from "@ignfab/gpf-schema-store"; +import { GpfFeatureType, wfsSchemaStore } from "../wfs/catalog.js"; import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import logger from "../logger.js"; +import { getGeometryProperties } from "../wfs/properties.js"; // --- Schema --- @@ -20,16 +21,74 @@ const gpfDescribeTypeInputSchema = z.object({ .describe("Le nom du type à décrire (de la forme `prefixe:nom`)."), }).strict(); -// FIXME: when mcp-framework is removed, remove this patch which is only here -// because mcp-framework does not accept z.record field types. -const gpfDescribeTypeOutput = zOgcCollectionSchema - .omit({ properties: true }) - .catchall(z.unknown()); +const gpfPropertySchema = z.object({ + name: z.string().describe("Le nom de la propriété."), + description_cut: z.string().max(101).optional().describe("La description de la propriété, tronquée à 100 caractères (terminaison si troncature : …)."), + oneOf: z.array(z.string()).optional().describe("La liste des valeurs possibles, si elle existe.") +}) +const ogcGeometryKind = [ + "point", + "multipoint", + "point-or-multipoint", + "linestring", + "multilinestring", + "linestring-or-multilinestring", + "polygon", + "multipolygon", + "polygon-or-multipolygon", + "geometrycollection", + "any" +] as const; + +const gpfDescribeTypeOutput = z.object({ + typename: z.string().describe("L'identifiant du type (de la forme `prefixe:nom`)."), + url: z.string().url().describe("Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type et gpf_describe_type_details suffisent, ne télécharge le schéma complet que lorsque les résultats ne sont pas satisfaisants."), + description: z.string().optional().describe("La description du contenu du type."), + geometry_kind: z.enum(ogcGeometryKind).optional().describe("Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise `spatial_extra` si c'est possible ; sinon, utilise un tool `_layer` pour télécharger la géométrie."), + properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles."), +}) // --- Types --- type GpfDescribeTypeInput = z.infer; +type GpfDescribeTypeOutput = z.infer; + +// --- Utility --- + +function truncateDescription(s: string, len: number) { + if (s.length > len) { + return s.substring(0, len) + "…"; + } + return s; +} + +function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { + const schema = featureType.schema; + const geometricPropertyNames = getGeometryProperties(featureType); + const mainGeometries = geometricPropertyNames.length < 2 ? geometricPropertyNames : + geometricPropertyNames.filter((s : string) => schema.properties[s]["x-ogc-role"] == "primary-geometry"); + const geometry_kind = mainGeometries.length == 0 ? undefined : schema.properties[mainGeometries[0]].format; + const shortProperties = Object.keys(schema.properties) + .filter((name: string) => !geometricPropertyNames.includes(name)) + .map((name: string) => { + const property = schema.properties[name]; + const description_cut = property.description ? truncateDescription(property.description, 100) : undefined + return { + name, + description_cut, + oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), + }; + }); + + return { + typename: featureType.typename, + url: schema["$id"], + description: schema.description, + geometry_kind: geometry_kind?.slice(9) as GpfDescribeTypeOutput["geometry_kind"], + properties: shortProperties, + }; +} // --- Tool --- @@ -38,15 +97,29 @@ class GpfDescribeTypeTool extends BaseTool { title = "Description d’un type GPF"; annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; description = [ - "Renvoie le schéma détaillé d'un type GPF à partir de son identifiant (`typename`).", - "Ce schéma contient notamment la description du type et un champ `properties` qui détaille, pour chaque propriété, son type, sa description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.", - "Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`.", + "Renvoie un résumé du schéma d'un type GPF à partir de son identifiant (`typename`).", + "Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec un début de description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.", + "Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`.", "**IMPORTANT : Appel fortement recommandé si les noms exacts des propriétés ne sont pas connus : un nom de propriété incorrect provoque une erreur**." ].join("\n"); protected outputSchemaShape = gpfDescribeTypeOutput; schema = gpfDescribeTypeInputSchema; + /** + * Formats the summary payload into both text content and structuredContent. + * + * @param data Raw execution result. + * @returns An MCP success response with validated output shape. + */ + protected createSuccessResponse(data: unknown) { + const payload = gpfDescribeTypeOutput.parse(data); + return { + content: [{ type: "text" as const, text: JSON.stringify(payload) }], + structuredContent: payload, + }; + } + /** * Loads the detailed schema description for one GPF typename. * @@ -60,7 +133,7 @@ class GpfDescribeTypeTool extends BaseTool { try { const featureType = await wfsSchemaStore.getFeatureType(input.typename); - return featureType.schema; + return summarizeSchema(featureType); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); throw new Error(`${message}. Utiliser gpf_search_types pour trouver un type valide.`); diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts index 9d662b86..87bd7b39 100644 --- a/src/wfs/properties.ts +++ b/src/wfs/properties.ts @@ -18,7 +18,7 @@ import type { GpfFeatureType } from "./catalog.js"; * @param featureType Feature type definition loaded from the embedded catalog. * @returns The list of spatial properties. */ -function getGeometryProperties(featureType: GpfFeatureType) { +export function getGeometryProperties(featureType: GpfFeatureType) { return Object.entries(featureType.schema.properties).filter(([_key, property]) => { // only geometric properties do not have a `type` field // (see OGC API Features, /req/schemas/properties A and B) diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 0c52d6f1..08008e71 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -9,18 +9,29 @@ import { expectToolCallToThrow } from "../helpers/level1-assertions.js"; import { INTEGRATION_CONFIG } from "../config/shared.js"; interface DescribeResult { - title: string; + typename: string; + url: string; description: string; - required: string[]; - properties: Record; +} + +interface DescribeDetailsResult { + typename: string; + properties: Array<{ + name: string; + type?: string; + required: boolean; description?: string; oneOf?: Array<{ const: string; - title: string; description?: string; }>; + extra_detail_fields?: string[]; }>; } @@ -32,11 +43,33 @@ describe("GPF Describe Type (integration)", () => { typename: "BDTOPO_V3:batiment", }); - expect(result.title).toBe("Bâtiment"); + expect(result.typename).toBe("BDTOPO_V3:batiment"); + expect(result.url).toContain("BDTOPO_V3"); expect(result.properties).toBeDefined(); - const propNames = Object.keys(result.properties); - expect(propNames.length).toBeGreaterThan(0); - expect(result.required).toBeDefined(); + expect(result.properties.length).toBeGreaterThan(0); + expect(result.properties[0].name).toBeDefined(); + }, INTEGRATION_CONFIG.timeout); + + it("should describe selected properties with gpf_describe_type_details", async () => { + const summary = await callTool(getHandle().client, "gpf_describe_type", { + typename: "BDTOPO_V3:batiment", + }); + + expect(summary.properties.length).toBeGreaterThan(0); + const selectedPropertyNames = summary.properties.slice(0, 2).map((property) => property.name); + + const result = await callTool( + getHandle().client, + "gpf_describe_type_details", + { + typename: "BDTOPO_V3:batiment", + select: selectedPropertyNames, + }, + ); + + expect(result.typename).toBe("BDTOPO_V3:batiment"); + expect(result.properties.length).toBe(selectedPropertyNames.length); + expect(result.properties.map((property) => property.name)).toEqual(selectedPropertyNames); }, INTEGRATION_CONFIG.timeout); it("should return an error for empty typename", async () => { diff --git a/test/integration/samples.ts b/test/integration/samples.ts index 2107b45c..0c595afa 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -21,6 +21,7 @@ export const EXPECTED_TOOL_NAMES = [ "assiette_sup", "gpf_search_types", "gpf_describe_type", + "gpf_describe_type_details", "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 7ed0db94..70a95c4e 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -1,155 +1,230 @@ -import { describe, it, expect } from "vitest"; +import { vi, describe, it, expect, afterEach } from "vitest"; import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; - -import GpfDescribeTypeTool from "../../../src/tools/GpfDescribeTypeTool"; +import type { GpfFeatureType } from "../../../src/wfs/catalog.js"; import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema"; -describe("Test GpfDescribeTypeTool",() => { - const mockCollection: OgcCollectionSchema = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://example.test/BDTOPO_V3/batiment.json', - type: "object", - title: "Batiment", - description: "Description de test", - properties: { - hauteur: { - type: "number" - } +const mockGetFeatureType = vi.fn<(typename: string) => Promise>(); + +vi.doMock("../../../src/wfs/catalog.js", () => ({ + wfsSchemaStore: { + getFeatureType: mockGetFeatureType, + }, +})); + +const { default: GpfDescribeTypeTool } = await import("../../../src/tools/GpfDescribeTypeTool"); + +describe("Test GpfDescribeTypeTool", () => { + const COMMUNE_TYPENAME = "ADMINEXPRESS-COG.LATEST:commune"; + + const communeType: OgcCollectionSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", + type: "object", + title: "Commune", + description: "Description de test", + properties: { + code_insee: { + type: "string", + description: "Code INSEE officiel de la commune", + }, + statut: { + type: "string", + description: "Type de statut administratif de la commune", + oneOf: [ + { + const: "A", + title: "Active", + description: "Commune active", + }, + { + const: "D", + title: "Déléguée", + description: "Commune déléguée", + }, + ], + }, + geometrie: { + format: "geometry-multipolygon", + "x-ogc-role": "primary-geometry", + }, + }, + required: ["code_insee"], + }; + + afterEach(() => { + vi.clearAllMocks(); + mockGetFeatureType.mockReset(); + }); + + it("should expose an enriched MCP definition", () => { + const tool = new GpfDescribeTypeTool(); + expect(tool.toolDefinition.title).toEqual("Description d’un type GPF"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it("should return both text content and structuredContent with summarized schema", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, }, - required: [] - }; + }, + }); - class TestableGpfDescribeTypeTool extends GpfDescribeTypeTool { - async execute(_: { typename: string }) { - return mockCollection; - } + 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"); } - class TestableGpfDescribeTypeToolError extends GpfDescribeTypeTool { - async execute(): Promise { - throw new Error("Le type 'BDTOPO_V3:not_found' est introuvable. Utiliser gpf_search_types pour trouver un type valide."); - } - } + const parsed = JSON.parse(textContent.text); + expect(parsed).toEqual(response.structuredContent); + expect(parsed).toMatchObject({ + typename: COMMUNE_TYPENAME, + url: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", + geometry_kind: "multipolygon", + }); + expect(parsed.properties).toHaveLength(2); + expect(parsed.properties.find((p: { name: string }) => p.name === "geometrie")).toBeUndefined(); + expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ + oneOf: ["A", "D"], + }); + }); + + it("should include a short description cut for non-geometry properties", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array<{ name: string; description_cut?: string }>; + }; + const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; + expect(descriptionCut).toEqual("Code INSEE officiel de la commune"); + }); - it("should expose an enriched MCP definition", () => { - const tool = new GpfDescribeTypeTool(); - expect(tool.toolDefinition.title).toEqual("Description d’un type GPF"); - expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + it("should keep successful output for long descriptions by allowing 103 chars", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ + typename: COMMUNE_TYPENAME, + schema: { + ...communeType, + properties: { + ...communeType.properties, + code_insee: { type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); + description: "X".repeat(150), + }, + }, + }, }); - it("should return both text content and structuredContent", async () => { - const tool = new TestableGpfDescribeTypeTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type", - arguments: { - typename: "BDTOPO_V3:batiment", - }, - }, - }); - - 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"); - } - expect(JSON.parse(textContent.text)).toMatchObject({ - title: "Batiment", - description: "Description de test", - }); - expect(response.structuredContent).toBeDefined(); - expect(response.structuredContent).toMatchObject({ - title: "Batiment", - description: "Description de test", - }); + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, }); - it("should return a payload that validates against its outputSchema", async () => { - const tool = new TestableGpfDescribeTypeTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type", - arguments: { - typename: "BDTOPO_V3:batiment", - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.structuredContent).toBeDefined(); - expect(tool.toolDefinition.outputSchema).toBeDefined(); - - expect( - validateStructuredContentAgainstOutputSchema( - tool.toolDefinition.outputSchema, - response.structuredContent, - ), - ).toBeNull(); + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array<{ name: string; description_cut?: string }>; + }; + const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; + expect(descriptionCut).toBeDefined(); + expect(descriptionCut?.length).toBe(101); + expect(descriptionCut?.endsWith("…")).toBe(true); + }); + + it("should return a payload that validates against its outputSchema", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, }); - it("should return isError=true for invalid input", async () => { - const tool = new GpfDescribeTypeTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type", - 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("Paramètres invalides"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "typename", - code: "too_small", - detail: "le nom du type ne doit pas être vide", - }), - ]), - }); + expect(response.isError).toBeUndefined(); + expect( + validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + ), + ).toBeNull(); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfDescribeTypeTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: "", + }, + }, }); - it("should return isError=true when execute fails", async () => { - const tool = new TestableGpfDescribeTypeToolError(); - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type", - arguments: { - typename: "BDTOPO_V3:not_found", - }, - }, - }); - - 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 type 'BDTOPO_V3:not_found' est introuvable"); - expect(textContent.text).toContain("gpf_search_types"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:execution-error", - }); + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: expect.arrayContaining([ + expect.objectContaining({ + name: "typename", + code: "too_small", + detail: "le nom du type ne doit pas être vide", + }), + ]), + }); + }); + + it("should return isError=true when catalog lookup fails", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockRejectedValue(new Error("Le type 'BDTOPO_V3:not_found' est introuvable")); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: "BDTOPO_V3:not_found", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("Le type 'BDTOPO_V3:not_found' est introuvable"); + expect(textContent.text).toContain("gpf_search_types"); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:execution-error", }); + }); }); diff --git a/test/tools/wfs/describeTypeDetails.test.ts b/test/tools/wfs/describeTypeDetails.test.ts new file mode 100644 index 00000000..e9e7ac27 --- /dev/null +++ b/test/tools/wfs/describeTypeDetails.test.ts @@ -0,0 +1,247 @@ +import { vi, describe, it, expect, afterEach } from "vitest"; + +import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; +import type { GpfFeatureType } from "../../../src/wfs/catalog.js"; +import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema"; + +const mockGetFeatureType = vi.fn<(typename: string) => Promise>(); + +vi.doMock("../../../src/wfs/catalog.js", () => ({ + wfsSchemaStore: { + getFeatureType: mockGetFeatureType, + }, +})); + +const { default: GpfDescribeTypeDetailsTool } = await import("../../../src/tools/GpfDescribeTypeDetailsTool"); + +describe("Test GpfDescribeTypeDetailsTool", () => { + const COMMUNE_TYPENAME = "ADMINEXPRESS-COG.LATEST:commune"; + + const communeType: OgcCollectionSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", + type: "object", + title: "Commune", + description: "Description de test", + properties: { + code_insee: { + type: "string", + description: "Code INSEE", + }, + statut: { + type: "string", + description: "Statut", + oneOf: [ + { + const: "A", + title: "Active", + description: "Commune active", + "x-ign-representedFeatures": ["Commune"], + }, + ], + "x-my-extra": "metadata", + } as OgcCollectionSchema["properties"][string], + geometrie: { + format: "geometry-multipolygon", + "x-ogc-role": "primary-geometry", + }, + }, + required: ["code_insee"], + }; + + afterEach(() => { + vi.clearAllMocks(); + mockGetFeatureType.mockReset(); + }); + + it("should expose an enriched MCP definition", () => { + const tool = new GpfDescribeTypeDetailsTool(); + expect(tool.toolDefinition.title).toEqual("Description des propriétés d’un type GPF"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ + type: "array", + items: { + type: "string", + }, + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it("should return both text content and structuredContent in short mode", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["code_insee", "statut"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const parsed = JSON.parse(textContent.text); + expect(parsed).toEqual(response.structuredContent); + expect(parsed).toMatchObject({ + typename: COMMUNE_TYPENAME, + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "code_insee", + required: true, + type: "string", + }), + ]), + }); + expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ + oneOf: [ + { + const: "A", + description: "Commune active", + }, + ], + extra_detail_fields: ["x-my-extra", "x-ign-representedFeatures"], + }); + }); + + it("should include full property schema when extra_details=true", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["statut"], + extra_details: true, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array>; + }; + expect(payload.properties[0]).toMatchObject({ + name: "statut", + type: "string", + required: false, + "x-my-extra": "metadata", + oneOf: [ + { + const: "A", + title: "Active", + }, + ], + }); + }); + + it("should return a payload that validates against its outputSchema", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["code_insee"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect( + validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + ), + ).toBeNull(); + }); + + it("should reject invalid selected properties", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["geometrie", "does_not_exist"], + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("propriétés non-géométriques"); + expect(textContent.text).toContain("geometrie, does_not_exist"); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:execution-error", + }); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: [], + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: expect.arrayContaining([ + expect.objectContaining({ + name: "select", + code: "too_small", + }), + ]), + }); + }); + + it("should return isError=true when catalog lookup fails", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockRejectedValue(new Error("Le type 'X:not_found' est introuvable")); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: "X:not_found", + select: ["code_insee"], + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("X:not_found"); + expect(textContent.text).toContain("gpf_search_types"); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:execution-error", + }); + }); +}); From 3d5933d744491f72be3925d3681a73503425eb4b Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 14 Aug 2026 18:35:15 +0200 Subject: [PATCH 3/5] fix: remove unused GpfDescribeTypeDetails tool and description truncation --- docs/mcp-tools.md | 170 +----------- src/tools/GpfDescribeTypeDetailsTool.ts | 195 -------------- src/tools/GpfDescribeTypeTool.ts | 29 +- .../level1-protocol/describe.test.ts | 39 +-- test/integration/samples.ts | 1 - test/tools/wfs/describeType.test.ts | 82 +++--- test/tools/wfs/describeTypeDetails.test.ts | 247 ------------------ 7 files changed, 64 insertions(+), 699 deletions(-) delete mode 100644 src/tools/GpfDescribeTypeDetailsTool.ts delete mode 100644 test/tools/wfs/describeTypeDetails.test.ts diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 7acefc4c..7d751289 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -53,7 +53,6 @@ Exemple complet généré automatiquement à partir d'un appel de tool invalide - [`gpf_count_features`](#gpf_count_features) - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) -- [`gpf_describe_type_details`](#gpf_describe_type_details) ## `geocode` @@ -959,7 +958,7 @@ Description d’un type GPF ``` Renvoie un résumé du schéma d'un type GPF à partir de son identifiant (`typename`). Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec un début de description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée. -Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`. +Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`. Si le résumé ne suffit pas, télécharger le schéma complet via l'`url` renvoyée. **IMPORTANT : Appel fortement recommandé si les noms exacts des propriétés ne sont pas connus : un nom de propriété incorrect provoque une erreur**. ``` @@ -995,10 +994,10 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo | Champ | Type | Requis | Description | | --- | --- | --- | --- | | `description` | string | non | La description du contenu du type. | -| `geometry_kind` | string (enum) | non | Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme "point-or-multipoint" ou encore "any". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.
Note : si tu as besoin d'une propriété géométrique dans une requête, utilise `spatial_extra` si c'est possible ; sinon, utilise un tool `_layer` pour télécharger la géométrie. Valeurs : point, multipoint, point-or-multipoint, linestring, multilinestring, linestring-or-multilinestring, polygon, multipolygon, polygon-or-multipolygon, geometrycollection, any. | -| `properties` | array | oui | La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles. | +| `geometry_kind` | string (enum) | non | Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme "point-or-multipoint" ou encore "any". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.
Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire. Valeurs : point, multipoint, point-or-multipoint, linestring, multilinestring, linestring-or-multilinestring, polygon, multipolygon, polygon-or-multipolygon, geometrycollection, any. | +| `properties` | array | oui | La liste des propriétés non géométriques du schéma. | | `typename` | string | oui | L'identifiant du type (de la forme `prefixe:nom`). | -| `url` | string | oui | Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type et gpf_describe_type_details suffisent, ne télécharge le schéma complet que lorsque les résultats ne sont pas satisfaisants. | +| `url` | string | oui | Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche. |
Schéma de sortie brut @@ -1013,7 +1012,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "url": { "type": "string", - "description": "Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type et gpf_describe_type_details suffisent, ne télécharge le schéma complet que lorsque les résultats ne sont pas satisfaisants.", + "description": "Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche.", "format": "uri" }, "description": { @@ -1022,7 +1021,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "geometry_kind": { "type": "string", - "description": "Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise `spatial_extra` si c'est possible ; sinon, utilise un tool `_layer` pour télécharger la géométrie.", + "description": "Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire.", "enum": [ "point", "multipoint", @@ -1039,7 +1038,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "properties": { "type": "array", - "description": "La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles.", + "description": "La liste des propriétés non géométriques du schéma.", "items": { "type": "object", "properties": { @@ -1047,10 +1046,9 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo "type": "string", "description": "Le nom de la propriété." }, - "description_cut": { + "description": { "type": "string", - "description": "Les 100 premiers caractères de la description de la propriété.", - "maxLength": 103 + "description": "La description de la propriété." }, "oneOf": { "type": "array", @@ -2218,153 +2216,3 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu | --- | --- | --- | --- | | 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_describe_type_details` - -Code Source : [src/tools/GpfDescribeTypeDetailsTool.ts](../src/tools/GpfDescribeTypeDetailsTool.ts) - -### Titre - -Description des propriétés d’un type GPF - -### Description du tool - -``` -Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF. -Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`. -Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`. -``` - -### Schéma d’entrée - -| Champ | Type | Requis | Description | -| --- | --- | --- | --- | -| `extra_details` | boolean | oui | Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs. Valeur par défaut : false. | -| `select` | array | oui | La liste des propriétés non géométriques sur lesquelles des détails sont requis. | -| `typename` | string | oui | Le nom du type à décrire (de la forme `prefixe:nom`). | - -
-Schéma d’entrée brut - -```json -{ - "type": "object", - "properties": { - "typename": { - "type": "string", - "description": "Le nom du type à décrire (de la forme `prefixe:nom`).", - "minLength": 1 - }, - "select": { - "type": "array", - "description": "La liste des propriétés non géométriques sur lesquelles des détails sont requis.", - "items": { - "type": "string" - } - }, - "extra_details": { - "type": "boolean", - "description": "Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.", - "default": false - } - }, - "required": [ - "typename", - "select", - "extra_details" - ] -} -``` - -
- -### Schéma de sortie - -| Champ | Type | Requis | Description | -| --- | --- | --- | --- | -| `properties` | array | oui | La liste des propriétés non géométriques demandées avec leurs détails. | -| `typename` | string | oui | L'identifiant du type. | - -
-Schéma de sortie brut - -```json -{ - "type": "object", - "properties": { - "typename": { - "type": "string", - "description": "L'identifiant du type." - }, - "properties": { - "type": "array", - "description": "La liste des propriétés non géométriques demandées avec leurs détails.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Le nom de la propriété." - }, - "description": { - "type": "string", - "description": "La description de la propriété." - }, - "type": { - "type": "string", - "description": "Le type de la propriété." - }, - "required": { - "type": "boolean", - "description": "Indique si la propriété est obligatoirement présente pour tous les objets du type." - }, - "oneOf": { - "type": "array", - "description": "La liste des valeurs possibles, si elle existe.", - "items": { - "type": "object", - "properties": { - "const": { - "type": "string", - "description": "La valeur possible." - }, - "description": { - "type": "string", - "description": "La signification de cette valeur." - } - }, - "required": [ - "const" - ] - } - }, - "extra_detail_fields": { - "type": "array", - "description": "La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`.", - "items": { - "type": "string" - } - } - }, - "required": [ - "name", - "required" - ] - } - } - }, - "required": [ - "typename", - "properties" - ] -} -``` - -
- -### Réponse MCP - -| Cas | `content` | `structuredContent` | Relation entre `content` et `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`. | diff --git a/src/tools/GpfDescribeTypeDetailsTool.ts b/src/tools/GpfDescribeTypeDetailsTool.ts deleted file mode 100644 index 9bf9e5d7..00000000 --- a/src/tools/GpfDescribeTypeDetailsTool.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * MCP tool exposing focused schema details for selected properties of one GPF type. - */ - -import BaseTool from "./BaseTool.js"; -import { z } from "zod"; - -import type { OgcCollectionProperty, OgcCollectionPropertyEnumValue, OgcCollectionSchema } from "@ignfab/gpf-schema-store"; -import { wfsSchemaStore } from "../wfs/catalog.js"; -import type { GpfFeatureType } from "../wfs/catalog.js"; -import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import logger from "../logger.js"; - -// --- Schema --- - -const gpfDescribeTypeDetailsInputSchema = z.object({ - typename: z - .string() - .trim() - .min(1, "le nom du type ne doit pas être vide") - .describe("Le nom du type à décrire (de la forme `prefixe:nom`)."), - select: z - .array(z.string().trim()) - .min(1, "il faut choisir au moins une propriété") - .describe("La liste des propriétés non géométriques sur lesquelles des détails sont requis."), - extra_details: z - .boolean() - .default(false) - .describe("Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.") -}).strict(); - -const gpfPropertyEnumSchema = z.object({ - const: z.string().describe("La valeur possible."), - description: z.string().optional().describe("La signification de cette valeur."), -}).catchall(z.unknown()); // catchall for when extra_details is required - -const gpfPropertyDetailsSchema = z.object({ - name: z.string().describe("Le nom de la propriété."), - description: z.string().optional().describe("La description de la propriété."), - type: z.string().optional().describe("Le type de la propriété."), - required: z.boolean().describe("Indique si la propriété est obligatoirement présente pour tous les objets du type."), - oneOf: z.array(gpfPropertyEnumSchema).optional().describe("La liste des valeurs possibles, si elle existe."), - extra_detail_fields: z.array(z.string()).optional().describe("La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`."), -}).catchall(z.unknown()); // catchall for when extra_details is required - -const gpfDescribeTypeDetailsOutput = z.object({ - typename: z.string().describe("L'identifiant du type."), - properties: z.array(gpfPropertyDetailsSchema).describe("La liste des propriétés non géométriques demandées avec leurs détails."), -}); - -// --- Types --- - -type GpfDescribeTypeDetailsInput = z.infer; -type GpfDescribePropertyDetailsOutput = z.infer; -type GpfDescribePropertyEnumOutput = z.infer; - -// --- Utility --- - -/** - * Lists keys present on a schema fragment but not exposed in the short output. - */ -function getExtraKeys(source: Record, knownKeys: readonly string[]) { - return Object.keys(source).filter((key) => !knownKeys.includes(key)); -} - -/** - * Normalizes oneOf values into the tool output shape. - */ -function extractOneOfDetails(oneOf: OgcCollectionPropertyEnumValue[] | undefined): GpfDescribePropertyEnumOutput[] | undefined { - if (!oneOf) { - return undefined; - } - - return oneOf.map((value: OgcCollectionPropertyEnumValue) => ({ - const: value.const, - description: value.description, - })); -} - -/** - * Lists extra field names available on oneOf values. - */ -function getOneOfExtraDetailFields(oneOf: OgcCollectionPropertyEnumValue[] | undefined) { - if (!oneOf) { - return []; - } - - return Array.from( - new Set( - oneOf.flatMap((value: OgcCollectionPropertyEnumValue) => - getExtraKeys(value as Record, ["const", "title", "description"]), - ), - ), - ); -} - -/** - * Extracts selected details for one non-geometry property. - */ -function extractDetails(name: string, schema: OgcCollectionSchema, extraDetails: boolean) : GpfDescribePropertyDetailsOutput { - const prop: OgcCollectionProperty = schema.properties[name]; - const required = schema.required.includes(name); - - if (extraDetails) { // return the full schema for the property - return { - ...prop, - name, - required, - }; - } - - const oneOf = extractOneOfDetails(prop.oneOf); - - const propertyExtraDetailFields = getExtraKeys( - prop as Record, - ["type", "title", "description", "format", "oneOf", "x-ogc-role"], - ); - const oneOfExtraDetailFields = getOneOfExtraDetailFields(prop.oneOf); - const extra_detail_fields = Array.from(new Set([...propertyExtraDetailFields, ...oneOfExtraDetailFields])); - - return { - name, - description: prop.description, - type: prop.type, - required, - oneOf, - extra_detail_fields, - }; -} - -// --- Tool --- - -class GpfDescribeTypeDetailsTool extends BaseTool { - name = "gpf_describe_type_details"; - title = "Description des propriétés d’un type GPF"; - annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; - description = [ - "Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF.", - "Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`.", - "Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`." - ].join("\n"); - protected outputSchemaShape = gpfDescribeTypeDetailsOutput; - - schema = gpfDescribeTypeDetailsInputSchema; - - /** - * Formats the details payload into both text content and structuredContent. - * - * @param data Raw execution result. - * @returns An MCP success response with validated output shape. - */ - protected createSuccessResponse(data: unknown) { - const payload = gpfDescribeTypeDetailsOutput.parse(data); - return { - content: [{ type: "text" as const, text: JSON.stringify(payload) }], - structuredContent: payload, - }; - } - - /** - * Loads the detailed schema description for one GPF typename. - * - * @param input Normalized tool input. - * @returns The detailed feature type description from the embedded catalog. - */ - async execute(input: GpfDescribeTypeDetailsInput) { - logger.info(`[tool] execute ${this.name} ...`, { - input: input - }); - - let featureType: GpfFeatureType; - - try { - featureType = await wfsSchemaStore.getFeatureType(input.typename); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - throw new Error(`${message}. Utiliser gpf_search_types pour trouver un type valide.`); - } - - const invalidProperties = input.select.filter((name: string) => { - const property = featureType.schema.properties[name]; - return !property || !property.type; - }); - if (invalidProperties.length > 0) { - throw new Error(`Le type ${featureType.typename} n'admet pas, parmi ses propriétés non-géométriques : ${invalidProperties.join(", ")}. Utiliser gpf_describe_type pour obtenir la liste des propriétés non-géométriques disponibles.`); - } - - return { - typename: featureType.typename, - properties: input.select.map((name: string) => extractDetails(name, featureType.schema, input.extra_details)), - }; - } -} - -export default GpfDescribeTypeDetailsTool; diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 554292bd..3c64ff0d 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -23,9 +23,9 @@ const gpfDescribeTypeInputSchema = z.object({ const gpfPropertySchema = z.object({ name: z.string().describe("Le nom de la propriété."), - description_cut: z.string().max(101).optional().describe("La description de la propriété, tronquée à 100 caractères (terminaison si troncature : …)."), + description: z.string().optional().describe("La description de la propriété."), oneOf: z.array(z.string()).optional().describe("La liste des valeurs possibles, si elle existe.") -}) +}); const ogcGeometryKind = [ "point", @@ -43,11 +43,11 @@ const ogcGeometryKind = [ const gpfDescribeTypeOutput = z.object({ typename: z.string().describe("L'identifiant du type (de la forme `prefixe:nom`)."), - url: z.string().url().describe("Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type et gpf_describe_type_details suffisent, ne télécharge le schéma complet que lorsque les résultats ne sont pas satisfaisants."), + url: z.string().url().describe("Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche."), description: z.string().optional().describe("La description du contenu du type."), - geometry_kind: z.enum(ogcGeometryKind).optional().describe("Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise `spatial_extra` si c'est possible ; sinon, utilise un tool `_layer` pour télécharger la géométrie."), - properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles."), -}) + geometry_kind: z.enum(ogcGeometryKind).optional().describe("Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire."), + properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma."), +}); // --- Types --- @@ -56,13 +56,6 @@ type GpfDescribeTypeOutput = z.infer; // --- Utility --- -function truncateDescription(s: string, len: number) { - if (s.length > len) { - return s.substring(0, len) + "…"; - } - return s; -} - function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { const schema = featureType.schema; const geometricPropertyNames = getGeometryProperties(featureType); @@ -73,10 +66,9 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { .filter((name: string) => !geometricPropertyNames.includes(name)) .map((name: string) => { const property = schema.properties[name]; - const description_cut = property.description ? truncateDescription(property.description, 100) : undefined return { name, - description_cut, + description: property.description, oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), }; }); @@ -85,6 +77,7 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { typename: featureType.typename, url: schema["$id"], description: schema.description, + // slice(9) below to remove the mandatory starting "geometry-" prefix geometry_kind: geometry_kind?.slice(9) as GpfDescribeTypeOutput["geometry_kind"], properties: shortProperties, }; @@ -99,7 +92,7 @@ class GpfDescribeTypeTool extends BaseTool { description = [ "Renvoie un résumé du schéma d'un type GPF à partir de son identifiant (`typename`).", "Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec un début de description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.", - "Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`.", + "Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`. Si le résumé ne suffit pas, télécharger le schéma complet via l'`url` renvoyée.", "**IMPORTANT : Appel fortement recommandé si les noms exacts des propriétés ne sont pas connus : un nom de propriété incorrect provoque une erreur**." ].join("\n"); protected outputSchemaShape = gpfDescribeTypeOutput; @@ -121,10 +114,10 @@ class GpfDescribeTypeTool extends BaseTool { } /** - * Loads the detailed schema description for one GPF typename. + * Loads and summarizes the schema description for one GPF typename. * * @param input Normalized tool input. - * @returns The detailed feature type description from the embedded catalog. + * @returns The summarized feature type description from the embedded catalog. */ async execute(input: GpfDescribeTypeInput) { logger.info(`[tool] execute ${this.name} ...`, { diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 08008e71..0d0555e4 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -15,23 +15,8 @@ interface DescribeResult { geometry_kind?: string; properties: Array<{ name: string; - description_cut?: string; - oneOf?: string[]; - }>; -} - -interface DescribeDetailsResult { - typename: string; - properties: Array<{ - name: string; - type?: string; - required: boolean; description?: string; - oneOf?: Array<{ - const: string; - description?: string; - }>; - extra_detail_fields?: string[]; + oneOf?: string[]; }>; } @@ -50,28 +35,6 @@ describe("GPF Describe Type (integration)", () => { expect(result.properties[0].name).toBeDefined(); }, INTEGRATION_CONFIG.timeout); - it("should describe selected properties with gpf_describe_type_details", async () => { - const summary = await callTool(getHandle().client, "gpf_describe_type", { - typename: "BDTOPO_V3:batiment", - }); - - expect(summary.properties.length).toBeGreaterThan(0); - const selectedPropertyNames = summary.properties.slice(0, 2).map((property) => property.name); - - const result = await callTool( - getHandle().client, - "gpf_describe_type_details", - { - typename: "BDTOPO_V3:batiment", - select: selectedPropertyNames, - }, - ); - - expect(result.typename).toBe("BDTOPO_V3:batiment"); - expect(result.properties.length).toBe(selectedPropertyNames.length); - expect(result.properties.map((property) => property.name)).toEqual(selectedPropertyNames); - }, INTEGRATION_CONFIG.timeout); - it("should return an error for empty typename", async () => { await expectToolCallToThrow(callTool(getHandle().client, "gpf_describe_type", { typename: "" })); }, INTEGRATION_CONFIG.timeout); diff --git a/test/integration/samples.ts b/test/integration/samples.ts index 0c595afa..2107b45c 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -21,7 +21,6 @@ export const EXPECTED_TOOL_NAMES = [ "assiette_sup", "gpf_search_types", "gpf_describe_type", - "gpf_describe_type_details", "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 70a95c4e..f013bbab 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -101,7 +101,7 @@ describe("Test GpfDescribeTypeTool", () => { }); }); - it("should include a short description cut for non-geometry properties", async () => { + it("should include a description for non-geometry properties", async () => { const tool = new GpfDescribeTypeTool(); mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); @@ -116,45 +116,10 @@ describe("Test GpfDescribeTypeTool", () => { expect(response.isError).toBeUndefined(); const payload = response.structuredContent as { - properties: Array<{ name: string; description_cut?: string }>; + properties: Array<{ name: string; description?: string }>; }; - const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; - expect(descriptionCut).toEqual("Code INSEE officiel de la commune"); - }); - - it("should keep successful output for long descriptions by allowing 103 chars", async () => { - const tool = new GpfDescribeTypeTool(); - mockGetFeatureType.mockResolvedValue({ - typename: COMMUNE_TYPENAME, - schema: { - ...communeType, - properties: { - ...communeType.properties, - code_insee: { - type: "string", - description: "X".repeat(150), - }, - }, - }, - }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type", - arguments: { - typename: COMMUNE_TYPENAME, - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const payload = response.structuredContent as { - properties: Array<{ name: string; description_cut?: string }>; - }; - const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; - expect(descriptionCut).toBeDefined(); - expect(descriptionCut?.length).toBe(101); - expect(descriptionCut?.endsWith("…")).toBe(true); + const description = payload.properties.find((p) => p.name === "code_insee")?.description; + expect(description).toEqual("Code INSEE officiel de la commune"); }); it("should return a payload that validates against its outputSchema", async () => { @@ -227,4 +192,43 @@ describe("Test GpfDescribeTypeTool", () => { type: "urn:geocontext:problem:execution-error", }); }); + + it("should select the primary geometry when several geometries exist", async () => { + const multiGeometryType: OgcCollectionSchema = { + ...communeType, + properties: { + code_insee: { + type: "string", + description: "Code INSEE officiel de la commune", + }, + geometrie: { + format: "geometry-multipolygon", + "x-ogc-role": "primary-geometry", + }, + emprise: { + format: "geometry-point", + }, + }, + }; + + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: multiGeometryType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + geometry_kind?: string; + properties: Array<{ name: string }>; + }; + expect(payload.geometry_kind).toEqual("multipolygon"); + expect(payload.properties.map((p) => p.name)).toEqual(["code_insee"]); + }); }); diff --git a/test/tools/wfs/describeTypeDetails.test.ts b/test/tools/wfs/describeTypeDetails.test.ts deleted file mode 100644 index e9e7ac27..00000000 --- a/test/tools/wfs/describeTypeDetails.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { vi, describe, it, expect, afterEach } from "vitest"; - -import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; -import type { GpfFeatureType } from "../../../src/wfs/catalog.js"; -import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema"; - -const mockGetFeatureType = vi.fn<(typename: string) => Promise>(); - -vi.doMock("../../../src/wfs/catalog.js", () => ({ - wfsSchemaStore: { - getFeatureType: mockGetFeatureType, - }, -})); - -const { default: GpfDescribeTypeDetailsTool } = await import("../../../src/tools/GpfDescribeTypeDetailsTool"); - -describe("Test GpfDescribeTypeDetailsTool", () => { - const COMMUNE_TYPENAME = "ADMINEXPRESS-COG.LATEST:commune"; - - const communeType: OgcCollectionSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", - type: "object", - title: "Commune", - description: "Description de test", - properties: { - code_insee: { - type: "string", - description: "Code INSEE", - }, - statut: { - type: "string", - description: "Statut", - oneOf: [ - { - const: "A", - title: "Active", - description: "Commune active", - "x-ign-representedFeatures": ["Commune"], - }, - ], - "x-my-extra": "metadata", - } as OgcCollectionSchema["properties"][string], - geometrie: { - format: "geometry-multipolygon", - "x-ogc-role": "primary-geometry", - }, - }, - required: ["code_insee"], - }; - - afterEach(() => { - vi.clearAllMocks(); - mockGetFeatureType.mockReset(); - }); - - it("should expose an enriched MCP definition", () => { - const tool = new GpfDescribeTypeDetailsTool(); - expect(tool.toolDefinition.title).toEqual("Description des propriétés d’un type GPF"); - expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ - type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ - type: "array", - items: { - type: "string", - }, - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); - }); - - it("should return both text content and structuredContent in short mode", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["code_insee", "statut"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const parsed = JSON.parse(textContent.text); - expect(parsed).toEqual(response.structuredContent); - expect(parsed).toMatchObject({ - typename: COMMUNE_TYPENAME, - properties: expect.arrayContaining([ - expect.objectContaining({ - name: "code_insee", - required: true, - type: "string", - }), - ]), - }); - expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ - oneOf: [ - { - const: "A", - description: "Commune active", - }, - ], - extra_detail_fields: ["x-my-extra", "x-ign-representedFeatures"], - }); - }); - - it("should include full property schema when extra_details=true", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["statut"], - extra_details: true, - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const payload = response.structuredContent as { - properties: Array>; - }; - expect(payload.properties[0]).toMatchObject({ - name: "statut", - type: "string", - required: false, - "x-my-extra": "metadata", - oneOf: [ - { - const: "A", - title: "Active", - }, - ], - }); - }); - - it("should return a payload that validates against its outputSchema", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["code_insee"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect( - validateStructuredContentAgainstOutputSchema( - tool.toolDefinition.outputSchema, - response.structuredContent, - ), - ).toBeNull(); - }); - - it("should reject invalid selected properties", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["geometrie", "does_not_exist"], - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("propriétés non-géométriques"); - expect(textContent.text).toContain("geometrie, does_not_exist"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:execution-error", - }); - }); - - it("should return isError=true for invalid input", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: [], - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:invalid-tool-params", - errors: expect.arrayContaining([ - expect.objectContaining({ - name: "select", - code: "too_small", - }), - ]), - }); - }); - - it("should return isError=true when catalog lookup fails", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockRejectedValue(new Error("Le type 'X:not_found' est introuvable")); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: "X:not_found", - select: ["code_insee"], - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("X:not_found"); - expect(textContent.text).toContain("gpf_search_types"); - expect(response.structuredContent).toMatchObject({ - type: "urn:geocontext:problem:execution-error", - }); - }); -}); From c1ac8ba6c5fed127299ace0932c835f66f3488b8 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Mon, 17 Aug 2026 13:33:37 +0200 Subject: [PATCH 4/5] feat: include "required" field in description --- src/tools/GpfDescribeTypeTool.ts | 7 ++++++- test/integration/level1-protocol/describe.test.ts | 2 ++ test/tools/wfs/describeType.test.ts | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 3c64ff0d..0b912fa2 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -46,7 +46,8 @@ const gpfDescribeTypeOutput = z.object({ url: z.string().url().describe("Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche."), description: z.string().optional().describe("La description du contenu du type."), geometry_kind: z.enum(ogcGeometryKind).optional().describe("Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire."), - properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma."), + properties: z.array(gpfPropertySchema).describe("La liste des propriétés non-géométriques du schéma."), + required: z.array(z.string()).describe("La liste des propriétés non-géométriques toujours présentes. Toute propriété qui n'est pas dans cette liste est donc facultative."), }); // --- Types --- @@ -72,6 +73,9 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), }; }); + const required = schema.required.filter( + (name: string) => !geometricPropertyNames.includes(name), + ); return { typename: featureType.typename, @@ -80,6 +84,7 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { // slice(9) below to remove the mandatory starting "geometry-" prefix geometry_kind: geometry_kind?.slice(9) as GpfDescribeTypeOutput["geometry_kind"], properties: shortProperties, + required, }; } diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 0d0555e4..2412ee38 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -13,6 +13,7 @@ interface DescribeResult { url: string; description: string; geometry_kind?: string; + required: string[]; properties: Array<{ name: string; description?: string; @@ -30,6 +31,7 @@ describe("GPF Describe Type (integration)", () => { expect(result.typename).toBe("BDTOPO_V3:batiment"); expect(result.url).toContain("BDTOPO_V3"); + expect(Array.isArray(result.required)).toBe(true); expect(result.properties).toBeDefined(); expect(result.properties.length).toBeGreaterThan(0); expect(result.properties[0].name).toBeDefined(); diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index f013bbab..2caca50d 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -93,6 +93,7 @@ describe("Test GpfDescribeTypeTool", () => { typename: COMMUNE_TYPENAME, url: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", geometry_kind: "multipolygon", + required: ["code_insee"], }); expect(parsed.properties).toHaveLength(2); expect(parsed.properties.find((p: { name: string }) => p.name === "geometrie")).toBeUndefined(); @@ -209,6 +210,7 @@ describe("Test GpfDescribeTypeTool", () => { format: "geometry-point", }, }, + required: ["code_insee", "geometrie"], }; const tool = new GpfDescribeTypeTool(); @@ -227,8 +229,10 @@ describe("Test GpfDescribeTypeTool", () => { const payload = response.structuredContent as { geometry_kind?: string; properties: Array<{ name: string }>; + required: string[]; }; expect(payload.geometry_kind).toEqual("multipolygon"); expect(payload.properties.map((p) => p.name)).toEqual(["code_insee"]); + expect(payload.required).toEqual(["code_insee"]); }); }); From a553fe527a4005dc77019bfcb15ab22060e7f48a Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Mon, 17 Aug 2026 13:45:21 +0200 Subject: [PATCH 5/5] feat: add selection_criteria field in description --- src/tools/GpfDescribeTypeTool.ts | 2 ++ .../level1-protocol/describe.test.ts | 3 +++ test/tools/wfs/describeType.test.ts | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 0b912fa2..d956e90b 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -48,6 +48,7 @@ const gpfDescribeTypeOutput = z.object({ geometry_kind: z.enum(ogcGeometryKind).optional().describe("Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire."), properties: z.array(gpfPropertySchema).describe("La liste des propriétés non-géométriques du schéma."), required: z.array(z.string()).describe("La liste des propriétés non-géométriques toujours présentes. Toute propriété qui n'est pas dans cette liste est donc facultative."), + selection_criteria: z.string().optional().describe("Les critères de sélection des objets enregistrés dans ce type."), }); // --- Types --- @@ -85,6 +86,7 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { geometry_kind: geometry_kind?.slice(9) as GpfDescribeTypeOutput["geometry_kind"], properties: shortProperties, required, + selection_criteria: schema["x-ign-selectionCriteria"], }; } diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 2412ee38..5aa73023 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -14,6 +14,7 @@ interface DescribeResult { description: string; geometry_kind?: string; required: string[]; + selection_criteria?: string; properties: Array<{ name: string; description?: string; @@ -32,6 +33,8 @@ describe("GPF Describe Type (integration)", () => { expect(result.typename).toBe("BDTOPO_V3:batiment"); expect(result.url).toContain("BDTOPO_V3"); expect(Array.isArray(result.required)).toBe(true); + expect(result.selection_criteria).toBeDefined(); + expect(result.selection_criteria).toMatch(/50 m²/) expect(result.properties).toBeDefined(); expect(result.properties.length).toBeGreaterThan(0); expect(result.properties[0].name).toBeDefined(); diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 2caca50d..7d0e160e 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -50,6 +50,7 @@ describe("Test GpfDescribeTypeTool", () => { }, }, required: ["code_insee"], + "x-ign-selectionCriteria": "Code INSEE officiel non vide", }; afterEach(() => { @@ -94,6 +95,7 @@ describe("Test GpfDescribeTypeTool", () => { url: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", geometry_kind: "multipolygon", required: ["code_insee"], + selection_criteria: "Code INSEE officiel non vide", }); expect(parsed.properties).toHaveLength(2); expect(parsed.properties.find((p: { name: string }) => p.name === "geometrie")).toBeUndefined(); @@ -123,6 +125,27 @@ describe("Test GpfDescribeTypeTool", () => { expect(description).toEqual("Code INSEE officiel de la commune"); }); + it("should omit selection_criteria when not provided by the schema", async () => { + const tool = new GpfDescribeTypeTool(); + const { ["x-ign-selectionCriteria"]: _ignored, ...schemaWithoutCriteria } = communeType; + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: schemaWithoutCriteria }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + selection_criteria?: string; + }; + expect(payload.selection_criteria).toBeUndefined(); + }); + it("should return a payload that validates against its outputSchema", async () => { const tool = new GpfDescribeTypeTool(); mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType });