diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 18b96fb5..7d751289 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) @@ -967,9 +956,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 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**. ``` @@ -1004,15 +993,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 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 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 @@ -1021,48 +1006,68 @@ 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 suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche.", "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 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", + "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.", "items": { - "type": "string" + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Le nom de la propriété." + }, + "description": { + "type": "string", + "description": "La description de la propriété." + }, + "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" ] } ``` 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..d956e90b 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 { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.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,27 +21,85 @@ 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: 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", + "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 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."), + 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 --- type GpfDescribeTypeInput = z.infer; +type GpfDescribeTypeOutput = z.infer; + +// --- Utility --- + +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]; + return { + name, + description: property.description, + oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), + }; + }); + const required = schema.required.filter( + (name: string) => !geometricPropertyNames.includes(name), + ); + + return { + 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, + required, + selection_criteria: schema["x-ign-selectionCriteria"], + }; +} // --- Tool --- 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.", - "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 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; @@ -48,10 +107,24 @@ class GpfDescribeTypeTool extends BaseTool { schema = gpfDescribeTypeInputSchema; /** - * Loads the detailed schema description for one GPF typename. + * 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 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} ...`, { @@ -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/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.", 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..5aa73023 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -9,18 +9,16 @@ import { expectToolCallToThrow } from "../helpers/level1-assertions.js"; import { INTEGRATION_CONFIG } from "../config/shared.js"; interface DescribeResult { - title: string; + typename: string; + url: string; description: string; + geometry_kind?: string; required: string[]; - properties: Record; + oneOf?: string[]; }>; } @@ -32,11 +30,14 @@ 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(Array.isArray(result.required)).toBe(true); + expect(result.selection_criteria).toBeDefined(); + expect(result.selection_criteria).toMatch(/50 m²/) 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 return an error for empty typename", async () => { diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 7ed0db94..7d0e160e 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -1,155 +1,261 @@ -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"], + "x-ign-selectionCriteria": "Code INSEE officiel non vide", + }; + + 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", + 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(); + expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ + oneOf: ["A", "D"], + }); + }); - 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 include a description 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, + }, + }, }); - 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", - }); + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array<{ name: string; description?: string }>; + }; + const description = payload.properties.find((p) => p.name === "code_insee")?.description; + 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, + }, + }, }); - 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 { + 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 }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + 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: "", + }, + }, + }); + + 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", + }, + }, }); - 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).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", }); + }); - 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", - }); + 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", + }, + }, + required: ["code_insee", "geometrie"], + }; + + 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 }>; + required: string[]; + }; + expect(payload.geometry_kind).toEqual("multipolygon"); + expect(payload.properties.map((p) => p.name)).toEqual(["code_insee"]); + expect(payload.required).toEqual(["code_insee"]); + }); });